├── .editorconfig ├── .github └── workflows │ └── django.yml ├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.md ├── gdpr ├── __init__.py ├── anonymizers │ ├── __init__.py │ ├── base.py │ ├── fields.py │ ├── generic_relation.py │ ├── gis.py │ ├── hash_fields.py │ ├── local │ │ ├── __init__.py │ │ └── cs.py │ └── model_anonymizers.py ├── encryption.py ├── enums.py ├── fields.py ├── ipcypher.py ├── loading.py ├── locale │ └── cs │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── anonymize_data.py │ │ └── deactivate_expired_reasons.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20180509_1518.py │ ├── 0003.py │ ├── 0004.py │ ├── 0005_anon_2_0.py │ ├── 0006_auto_20190228_0725.py │ ├── 0007_migration.py │ ├── 0008_migration.py │ ├── 0009_migration.py │ ├── 0010_migration.py │ ├── 0011_migration.py │ └── __init__.py ├── mixins.py ├── models.py ├── purposes │ ├── __init__.py │ └── default.py ├── tests │ ├── __init__.py │ ├── test_cs_fields.py │ ├── test_encryption.py │ ├── test_fields.py │ ├── test_gis_fields.py │ └── test_ipcypher.py ├── utils.py └── version.py ├── manage.py ├── runtests.py ├── setup.cfg ├── setup.py ├── test_file ├── test_requirements.txt └── tests ├── __init__.py ├── anonymizers.py ├── migrations ├── 0001_initial.py ├── 0002_note.py ├── 0003_IBAN.py ├── 0004_facebook_id.py ├── 0005_avatar.py ├── 0006_auto_20190305_0323.py ├── 0007_childe_extraparentd_parentb_parentc_topparenta.py ├── 0008_customerregistration.py └── __init__.py ├── models.py ├── purposes.py ├── test_settings.py ├── tests ├── __init__.py ├── data.py ├── test_deactivate_expired_reasons.py ├── test_empty.py ├── test_fields.py ├── test_legal_reason.py ├── test_model_anonymization.py └── utils.py └── validators.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | indent_style = space 14 | indent_size = 4 15 | 16 | [*.py] 17 | indent_style = space 18 | indent_size = 4 19 | line_length = 120 20 | known_third_party = factory, ipware, responses, requests, nose, south, coverage, suds, geopy, user_agents, celery, vokativ, dateutil, jsonfield, easymode, PIL, pillow, mock, xlrd, attrdict, jinja2 21 | multi_line_output = 5 22 | lines_after_imports = 2 23 | known_django = django 24 | known_chamber = chamber 25 | sections = FUTURE, STDLIB, THIRDPARTY, DJANGO, CHAMBER, FIRSTPARTY, LOCALFOLDER 26 | 27 | 28 | [*.md] 29 | trim_trailing_whitespace = false 30 | 31 | [flake8] 32 | max_line_length = 120 33 | -------------------------------------------------------------------------------- /.github/workflows/django.yml: -------------------------------------------------------------------------------- 1 | name: Django CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | max-parallel: 4 14 | matrix: 15 | python-version: [3.8, 3.9] 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: Install Dependencies 24 | run: | 25 | python setup.py install 26 | pip install -r test_requirements.txt 27 | - name: Run Tests 28 | run: | 29 | python runtests.py 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | db.sqlite3 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # IPython 77 | profile_default/ 78 | ipython_config.py 79 | 80 | # pyenv 81 | .python-version 82 | 83 | # celery beat schedule file 84 | celerybeat-schedule 85 | 86 | # SageMath parsed files 87 | *.sage.py 88 | 89 | # Environments 90 | .env 91 | .venv 92 | env/ 93 | venv/ 94 | ENV/ 95 | env.bak/ 96 | venv.bak/ 97 | 98 | # Spyder project settings 99 | .spyderproject 100 | .spyproject 101 | 102 | # Rope project settings 103 | .ropeproject 104 | 105 | # mkdocs documentation 106 | /site 107 | 108 | # mypy 109 | .mypy_cache/ 110 | .dmypy.json 111 | dmypy.json 112 | 113 | # Pyre type checker 114 | .pyre/ 115 | 116 | .idea 117 | 118 | *.sqlite3 119 | 120 | .DS_Store 121 | *.swp 122 | *.tmp 123 | *.pyc 124 | *~ 125 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: required 3 | dist: xenial 4 | python: 5 | - "3.6" 6 | - "3.7" 7 | - "3.8" 8 | - "3.6" 9 | env: 10 | - DJANGO_VERSION=2.2 11 | - DJANGO_VERSION=3.0 12 | - DJANGO_VERSION=3.1 13 | # command to install dependencies 14 | install: 15 | - sed -i '/django==/d' ./test_requirements.txt 16 | - sed -i '/django-reversion/d' ./test_requirements.txt 17 | - pip install -q -r test_requirements.txt 18 | - pip install -q Django==$DJANGO_VERSION 19 | - python setup.py -q install 20 | - pip freeze 21 | # command to run tests 22 | script: 23 | - python runtests.py 24 | - flake8 . 25 | - mypy . 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 django-GDPR 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 LICENSE 2 | include README.md 3 | include MANIFEST.in 4 | recursive-include gdpr *.py *.txt *.html *.po *.mo *.xml *.xslt *.xsl *.js *.sass *.css *.png *.jpg *.jpeg *.gif 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django-GDPR [![Build Status](https://travis-ci.org/BrnoPCmaniak/django-GDPR.svg?branch=develop)](https://travis-ci.org/BrnoPCmaniak/django-GDPR) 2 | 3 | This library enables you to store user's consent for data retention easily 4 | and to anonymize/deanonymize user's data accordingly. 5 | 6 | For brief overview you can check example app in `tests` directory. 7 | 8 | # Quickstart 9 | 10 | Install django-gdpr with pip: 11 | 12 | ```bash 13 | pip install django-gdpr 14 | ``` 15 | 16 | Add gdpr to your `INSTALLED_APPS`: 17 | 18 | ```python 19 | INSTALLED_APPS = [ 20 | # Django apps... 21 | 'gdpr', 22 | ] 23 | ``` 24 | 25 | Imagine having a customer model: 26 | 27 | ```python 28 | # app/models.py 29 | 30 | from django.db import models 31 | 32 | from gdpr.mixins import AnonymizationModel 33 | 34 | class Customer(AnonymizationModel): 35 | # these fields will be used as basic keys for pseudoanonymization 36 | first_name = models.CharField(max_length=256) 37 | last_name = models.CharField(max_length=256) 38 | 39 | birth_date = models.DateField(blank=True, null=True) 40 | personal_id = models.CharField(max_length=10, blank=True, null=True) 41 | phone_number = models.CharField(max_length=9, blank=True, null=True) 42 | fb_id = models.CharField(max_length=256, blank=True, null=True) 43 | last_login_ip = models.GenericIPAddressField(blank=True, null=True) 44 | ``` 45 | 46 | You may want a consent to store all user's data for two years and consent to store first and last name for 10 years. 47 | For that you can simply add new consent purposes like this. 48 | 49 | ```python 50 | # app/purposes.py 51 | 52 | from dateutil.relativedelta import relativedelta 53 | 54 | from gdpr.purposes.default import AbstractPurpose 55 | 56 | GENERAL_PURPOSE_SLUG = "general" 57 | FIRST_AND_LAST_NAME_SLUG = "first_and_last" 58 | 59 | class GeneralPurpose(AbstractPurpose): 60 | name = "Retain user data for 2 years" 61 | slug = GENERAL_PURPOSE_SLUG 62 | expiration_timedelta = relativedelta(years=2) 63 | fields = "__ALL__" # Anonymize all fields defined in anonymizer 64 | 65 | class FirstAndLastNamePurpose(AbstractPurpose): 66 | """Store First & Last name for 10 years.""" 67 | name = "retain due to internet archive" 68 | slug = FIRST_AND_LAST_NAME_SLUG 69 | expiration_timedelta = relativedelta(years=10) 70 | fields = ("first_name", "last_name") 71 | ``` 72 | 73 | The field `fields` specify to which fields this consent applies to. 74 | 75 | Some more examples: 76 | ```python 77 | fields = ("first_name", "last_name") # Anonymize only two fields 78 | 79 | # You can also add nested fields to anonymize fields on related objects. 80 | fields = ( 81 | "primary_email_address", 82 | ("emails", ( 83 | "email", 84 | )), 85 | ) 86 | 87 | # Some more advanced configs may look like this: 88 | fields = ( 89 | "__ALL__", 90 | ("addresses", "__ALL__"), 91 | ("accounts", ( 92 | "__ALL__", 93 | ("payments", ( 94 | "__ALL__", 95 | )) 96 | )), 97 | ("emails", ( 98 | "email", 99 | )), 100 | ) 101 | 102 | ``` 103 | 104 | Now when we have the purpose(s) created we also have to make an _anonymizer_ so the library knows which fields to 105 | anonymize and how. This is fairly simple and is quite similar to Django forms. 106 | 107 | ```python 108 | # app/anonymizers.py 109 | 110 | from gdpr import anonymizers 111 | from tests.models import Customer 112 | 113 | 114 | class CustomerAnonymizer(anonymizers.ModelAnonymizer): 115 | first_name = anonymizers.MD5TextFieldAnonymizer() 116 | last_name = anonymizers.MD5TextFieldAnonymizer() 117 | primary_email_address = anonymizers.EmailFieldAnonymizer() 118 | 119 | birth_date = anonymizers.DateFieldAnonymizer() 120 | personal_id = anonymizers.PersonalIIDFieldAnonymizer() 121 | phone_number = anonymizers.PhoneFieldAnonymizer() 122 | fb_id = anonymizers.CharFieldAnonymizer() 123 | last_login_ip = anonymizers.IPAddressFieldAnonymizer() 124 | 125 | class Meta: 126 | model = Customer 127 | ``` 128 | 129 | Now you can fully leverage the system: 130 | 131 | You can create/revoke consent: 132 | ```python 133 | from gdpr.models import LegalReason 134 | 135 | from tests.models import Customer 136 | from tests.purposes import FIRST_AND_LAST_NAME_SLUG 137 | 138 | 139 | customer = Customer(first_name="John", last_name="Smith") 140 | customer.save() 141 | 142 | # Create consent 143 | LegalReason.objects.create_consent(FIRST_AND_LAST_NAME_SLUG, customer) 144 | 145 | # And now you can revoke it 146 | LegalReason.objects.deactivate_consent(FIRST_AND_LAST_NAME_SLUG, customer) 147 | ``` 148 | 149 | In case your model uses the `AnonymizationModelMixin` or `AnonymizationModel` you can create and revoke consents even 150 | easier. 151 | ```python 152 | from tests.models import Customer 153 | from tests.purposes import FIRST_AND_LAST_NAME_SLUG 154 | 155 | 156 | customer = Customer(first_name="John", last_name="Smith") 157 | customer.save() 158 | 159 | # Create consent 160 | customer.create_consent(FIRST_AND_LAST_NAME_SLUG) 161 | 162 | # And now you can revoke it 163 | customer.deactivate_consent(FIRST_AND_LAST_NAME_SLUG) 164 | ``` 165 | 166 | 167 | Expired consents are revoked by running the following command. You should invoke it repeatedly, for example by cron. 168 | The invocation interval depends on your circumstances - how fast you want to expire consents after their revocation, 169 | the amount of consents to expire in the interval, server load, and last but not least, legal requirements. 170 | 171 | ```python 172 | from gdpr.models import LegalReason 173 | 174 | LegalReason.objects.expire_old_consents() 175 | ``` 176 | 177 | ## FieldAnonymizers 178 | 179 | * `FunctionAnonymizer` - in place lambda/function anonymization method (e.g. `secret_code = anonymizers.FunctionFieldAnonymizer(lambda x: x**2)`) 180 | * `DateFieldAnonymizer` 181 | * `CharFieldAnonymizer` 182 | * `DecimalFieldAnonymizer` 183 | * `IPAddressFieldAnonymizer` 184 | * `CzechAccountNumberFieldAnonymizer` - for czech bank account numbers 185 | * `IBANFieldAnonymizer` 186 | * `JSONFieldAnonymizer` 187 | * `EmailFieldAnonymizer` 188 | * `MD5TextFieldAnonymizer` 189 | * `SHA256TextFieldAnonymizer` 190 | * `HashTextFieldAnonymizer` - anonymization using given hash algorithm (e.g. `secret_code = anonymizers.HashTextFieldAnonymizer('sha512')`) 191 | * `StaticValueFieldAnonymizer` - anonymization by replacing with static value (e.g. `secret_code = anonymizers.StaticValueFieldAnonymizer(42)`) 192 | -------------------------------------------------------------------------------- /gdpr/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/druids/django-GDPR/1595a652651100d5a1809c83a1516e5657cf2a6b/gdpr/__init__.py -------------------------------------------------------------------------------- /gdpr/anonymizers/__init__.py: -------------------------------------------------------------------------------- 1 | from .fields import ( 2 | CharFieldAnonymizer, DateFieldAnonymizer, DateTimeFieldAnonymizer, DecimalFieldAnonymizer, EmailFieldAnonymizer, 3 | FunctionFieldAnonymizer, IBANFieldAnonymizer, IPAddressFieldAnonymizer, SiteIDUsernameFieldAnonymizer, 4 | StaticValueFieldAnonymizer, DeleteFileFieldAnonymizer, IntegerFieldAnonymizer, ReplaceFileFieldAnonymizer) 5 | from .generic_relation import GenericRelationAnonymizer, ReverseGenericRelationAnonymizer 6 | from .hash_fields import HashTextFieldAnonymizer, MD5TextFieldAnonymizer, SHA256TextFieldAnonymizer 7 | from .model_anonymizers import DeleteModelAnonymizer, ModelAnonymizer 8 | 9 | __all__ = ( 10 | 'ModelAnonymizer', 'DeleteModelAnonymizer', 'FunctionFieldAnonymizer', 'DateFieldAnonymizer', 'CharFieldAnonymizer', 11 | 'DecimalFieldAnonymizer', 'IPAddressFieldAnonymizer', 'StaticValueFieldAnonymizer', 12 | 'MD5TextFieldAnonymizer', 'EmailFieldAnonymizer', 'DeleteFileFieldAnonymizer', 13 | 'ReverseGenericRelationAnonymizer', 'SHA256TextFieldAnonymizer', 14 | 'HashTextFieldAnonymizer', 'GenericRelationAnonymizer', 'IBANFieldAnonymizer', 'SiteIDUsernameFieldAnonymizer', 15 | 'DateTimeFieldAnonymizer', 'IntegerFieldAnonymizer', 'ReplaceFileFieldAnonymizer' 16 | ) 17 | -------------------------------------------------------------------------------- /gdpr/anonymizers/base.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Iterable, List, Optional, Union, Type 2 | 3 | from django.core.exceptions import ImproperlyConfigured 4 | from django.db.models import Model 5 | 6 | from gdpr.encryption import numerize_key 7 | from gdpr.utils import get_number_guess_len, get_reversion_local_field_dict 8 | from gdpr.loading import anonymizer_register 9 | 10 | 11 | class BaseAnonymizer: 12 | """ 13 | Base class for Anonymizers defining anonymizer type with properties: 14 | """ 15 | 16 | 17 | class RelationAnonymizer(BaseAnonymizer): 18 | """ 19 | Base class for Anonymizers defining special relations. 20 | """ 21 | 22 | model: Type[Model] 23 | 24 | def get_related_objects(self, obj: Model) -> Iterable: 25 | raise NotImplementedError 26 | 27 | @property 28 | def model_anonymizer(self): 29 | return anonymizer_register[self.model]() 30 | 31 | 32 | class FieldAnonymizer(BaseAnonymizer): 33 | """ 34 | Field anonymizer's purpose is to anonymize model field according to defined rule. 35 | """ 36 | 37 | ignore_empty_values: bool = True 38 | empty_values: List[Any] = [None] 39 | _encryption_key = None 40 | is_reversible: bool = True 41 | 42 | class IrreversibleAnonymizationException(Exception): 43 | pass 44 | 45 | def __init__(self, ignore_empty_values: bool = None, empty_values: Optional[List[Any]] = None): 46 | """ 47 | Args: 48 | ignore_empty_values: defines if empty value of a model will be ignored or should be anonymized too 49 | empty_values: defines list of values which are considered as empty 50 | """ 51 | self._ignore_empty_values = ignore_empty_values if ignore_empty_values is not None else self.ignore_empty_values 52 | self._empty_values = empty_values if empty_values is not None else self.empty_values 53 | 54 | def get_is_reversible(self, obj=None, raise_exception: bool = False) -> bool: 55 | """This method allows for custom implementation.""" 56 | if not self.is_reversible and raise_exception: 57 | raise self.IrreversibleAnonymizationException 58 | return self.is_reversible 59 | 60 | def get_ignore_empty_values(self, value): 61 | return self._ignore_empty_values 62 | 63 | def get_is_value_empty(self, value): 64 | return self.get_ignore_empty_values(value) and value in self._empty_values 65 | 66 | def _get_anonymized_value_from_value(self, value, encryption_key: str): 67 | if self.get_is_value_empty(value): 68 | return value 69 | return self.get_encrypted_value(value, encryption_key) 70 | 71 | def _get_deanonymized_value_from_value(self, obj, value, encryption_key: str): 72 | if self.get_is_reversible(obj, raise_exception=True): 73 | if self.get_is_value_empty(value): 74 | return value 75 | return self.get_decrypted_value(value, encryption_key) 76 | 77 | def get_value_from_obj(self, obj, name: str, encryption_key: str, anonymization: bool = True): 78 | if anonymization: 79 | return self._get_anonymized_value_from_value(getattr(obj, name), encryption_key) 80 | return self._get_deanonymized_value_from_value(obj, getattr(obj, name), encryption_key) 81 | 82 | def get_value_from_version(self, obj, version, name: str, encryption_key: str, anonymization: bool = True): 83 | if anonymization: 84 | return self._get_anonymized_value_from_value( 85 | get_reversion_local_field_dict(version)[name], encryption_key 86 | ) 87 | else: 88 | return self._get_deanonymized_value_from_value( 89 | obj, get_reversion_local_field_dict(version)[name], encryption_key 90 | ) 91 | 92 | def get_anonymized_value_from_obj(self, obj, name: str, encryption_key: str): 93 | return self.get_value_from_obj(obj, name, encryption_key, anonymization=True) 94 | 95 | def get_deanonymized_value_from_obj(self, obj, name: str, encryption_key: str): 96 | return self.get_value_from_obj(obj, name, encryption_key, anonymization=False) 97 | 98 | def get_anonymized_value_from_version(self, obj, version, name: str, encryption_key: str): 99 | return self.get_value_from_version(obj, version, name, encryption_key, anonymization=True) 100 | 101 | def get_deanonymized_value_from_version(self, obj, version, name: str, encryption_key: str): 102 | return self.get_value_from_version(obj, version, name, encryption_key, anonymization=False) 103 | 104 | def get_anonymized_value(self, value: Any) -> Any: 105 | """ 106 | Deprecated 107 | """ 108 | raise DeprecationWarning() 109 | 110 | def get_encrypted_value(self, value: Any, encryption_key: str) -> Any: 111 | """ 112 | There must be defined implementation of rule for anonymization 113 | 114 | :param value: value 115 | :param encryption_key: The encryption key 116 | :return: Encrypted value 117 | """ 118 | raise NotImplementedError 119 | 120 | def get_decrypted_value(self, value: Any, encryption_key: str) -> Any: 121 | """ 122 | There must be defined implementation of rule for deanonymization. 123 | 124 | :param value: Encrypted value 125 | :param encryption_key: The encryption key 126 | :return: Decrypted value 127 | """ 128 | if self.get_is_reversible(raise_exception=True): 129 | raise NotImplementedError 130 | 131 | 132 | class NumericFieldAnonymizer(FieldAnonymizer): 133 | max_anonymization_range: Optional[int] = None 134 | 135 | def __init__(self, max_anonymization_range: int = None, ignore_empty_values: bool = None, 136 | empty_values: Optional[List[Any]] = None): 137 | if max_anonymization_range is not None: 138 | self.max_anonymization_range = max_anonymization_range 139 | super().__init__(ignore_empty_values, empty_values) 140 | 141 | def get_numeric_encryption_key(self, encryption_key: str, value: Union[int, float] = None) -> int: 142 | """ 143 | From `encryption_key` create it's numeric counterpart of appropriate length. 144 | 145 | If value is supplied then the appropriate length is based on it if not the 146 | parameter `self.max_anonymization_range` is used. 147 | 148 | If the numeric_encryption_key is used the value+key may be one order bigger then the original value. 149 | 150 | e.g. value=5, numeric_encryption_key=8 => len('13') == 2 151 | 152 | :param encryption_key: The encryption key generated by anonymizer. 153 | :param value: Value to which the result of this function will be used. 154 | :return: Numeric counterpart of encryption_key 155 | """ 156 | if value is None: 157 | if self.max_anonymization_range is None: 158 | return numerize_key(encryption_key) 159 | return numerize_key(encryption_key) % self.max_anonymization_range 160 | 161 | return numerize_key(encryption_key) % 10 ** get_number_guess_len(value) 162 | -------------------------------------------------------------------------------- /gdpr/anonymizers/fields.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import timedelta 3 | from typing import Any, Callable, Optional, Union 4 | 5 | from django.conf import settings 6 | from django.core.exceptions import ImproperlyConfigured, ValidationError 7 | from django.core.files.base import ContentFile, File 8 | from django.db.models.fields.files import FieldFile 9 | from django.utils.inspect import func_supports_parameter 10 | from unidecode import unidecode 11 | 12 | from gdpr.anonymizers.base import FieldAnonymizer, NumericFieldAnonymizer 13 | from gdpr.encryption import ( 14 | JSON_SAFE_CHARS, decrypt_email_address, decrypt_text, encrypt_email_address, encrypt_text, numerize_key, 15 | translate_iban, translate_number, translate_text) 16 | from gdpr.ipcypher import decrypt_ip, encrypt_ip 17 | from gdpr.utils import get_number_guess_len 18 | 19 | 20 | class FunctionFieldAnonymizer(FieldAnonymizer): 21 | """ 22 | Use this field anonymization for defining in place lambda anonymization method. 23 | 24 | Example: 25 | ``` 26 | secret_code = FunctionFieldAnonymizer(lambda self, x, key: x**2) 27 | ``` 28 | 29 | If you want to supply anonymization and deanonymization you can do following: 30 | ``` 31 | secret_code = FunctionFieldAnonymizer( 32 | func=lambda self, x, key: x**2+self.get_numeric_encryption_key(key), 33 | deanonymize_func=lambda a, x, key: x**2-a.get_numeric_encryption_key(key) 34 | ) 35 | ``` 36 | """ 37 | 38 | anon_func: Callable 39 | deanonymize_func: Optional[Callable] = None 40 | max_anonymization_range: int 41 | 42 | def __init__(self, 43 | anon_func: Union[Callable[[Any, Any], Any], Callable[["FunctionFieldAnonymizer", Any, Any], Any]], 44 | deanonymize_func: Callable[["FunctionFieldAnonymizer", Any, Any], Any] = None, 45 | *args, **kwargs) -> None: 46 | super().__init__(*args, **kwargs) 47 | if callable(anon_func): 48 | self.anon_func = anon_func # type: ignore 49 | else: 50 | raise ImproperlyConfigured('Supplied func is not callable.') 51 | 52 | if callable(deanonymize_func): 53 | self.deanonymize_func = deanonymize_func 54 | elif deanonymize_func is not None: 55 | raise ImproperlyConfigured('Supplied deanonymize_func is not callable.') 56 | 57 | def get_numeric_encryption_key(self, encryption_key: str) -> int: 58 | return numerize_key(encryption_key) % self.max_anonymization_range 59 | 60 | def get_encrypted_value(self, value, encryption_key: str): 61 | if self.deanonymize_func is None: 62 | return self.anon_func(value, encryption_key) 63 | else: 64 | return self.anon_func(self, value, encryption_key) 65 | 66 | def get_is_reversible(self, obj=None, raise_exception: bool = False): 67 | is_reversible = self.deanonymize_func is not None 68 | if not is_reversible: 69 | raise self.IrreversibleAnonymizationException 70 | return is_reversible 71 | 72 | def get_decrypted_value(self, value, encryption_key: str): 73 | if not self.get_is_reversible(): 74 | raise self.IrreversibleAnonymizationException() 75 | else: 76 | return self.deanonymize_func(self, value, encryption_key) # type: ignore 77 | 78 | 79 | class DateTimeFieldAnonymizer(NumericFieldAnonymizer): 80 | """ 81 | Anonymization for DateTimeField. 82 | 83 | """ 84 | 85 | max_anonymization_range = 365 * 24 * 60 * 60 86 | 87 | def get_encrypted_value(self, value, encryption_key: str): 88 | return value - timedelta(seconds=(self.get_numeric_encryption_key(encryption_key) + 1)) 89 | 90 | def get_decrypted_value(self, value, encryption_key: str): 91 | return value + timedelta(seconds=(self.get_numeric_encryption_key(encryption_key) + 1)) 92 | 93 | 94 | class DateFieldAnonymizer(NumericFieldAnonymizer): 95 | """ 96 | Anonymization for DateField. 97 | 98 | """ 99 | 100 | max_anonymization_range = 365 101 | 102 | def get_encrypted_value(self, value, encryption_key: str): 103 | return value - timedelta(days=(self.get_numeric_encryption_key(encryption_key) + 1)) 104 | 105 | def get_decrypted_value(self, value, encryption_key: str): 106 | return value + timedelta(days=(self.get_numeric_encryption_key(encryption_key) + 1)) 107 | 108 | 109 | class CharFieldAnonymizer(FieldAnonymizer): 110 | """ 111 | Anonymization for CharField. 112 | 113 | transliterate - The CharFieldAnonymizer encrypts only ASCII chars and non-ascii chars are left the same e.g.: 114 | `François` -> `rbTTç]3d` if True the original text is transliterated e.g. `François` -> 'Francois' -> `rbTTQ9Zg`. 115 | """ 116 | 117 | transliterate = False 118 | empty_values = [None, ''] 119 | 120 | def __init__(self, *args, transliterate: bool = False, **kwargs): 121 | self.transliterate = transliterate 122 | super().__init__(*args, **kwargs) 123 | 124 | def get_encrypted_value(self, value, encryption_key: str): 125 | return encrypt_text(encryption_key, value if not self.transliterate else unidecode(value)) 126 | 127 | def get_decrypted_value(self, value, encryption_key: str): 128 | return decrypt_text(encryption_key, value) 129 | 130 | 131 | class EmailFieldAnonymizer(FieldAnonymizer): 132 | 133 | empty_values = [None, ''] 134 | 135 | def get_encrypted_value(self, value, encryption_key: str): 136 | return encrypt_email_address(encryption_key, value) 137 | 138 | def get_decrypted_value(self, value, encryption_key: str): 139 | return decrypt_email_address(encryption_key, value) 140 | 141 | 142 | class DecimalFieldAnonymizer(NumericFieldAnonymizer): 143 | """ 144 | Anonymization for CharField. 145 | """ 146 | 147 | def get_encrypted_value(self, value, encryption_key: str): 148 | return translate_number(str(self.get_numeric_encryption_key(encryption_key)), value) 149 | 150 | def get_decrypted_value(self, value, encryption_key: str): 151 | return translate_number(str(self.get_numeric_encryption_key(encryption_key)), value, encrypt=False) 152 | 153 | 154 | class IntegerFieldAnonymizer(NumericFieldAnonymizer): 155 | """ 156 | Anonymization for IntegerField. 157 | """ 158 | 159 | def get_encrypted_value(self, value, encryption_key: str): 160 | return translate_number(str(self.get_numeric_encryption_key(encryption_key)), value) 161 | 162 | def get_decrypted_value(self, value, encryption_key: str): 163 | return translate_number(str(self.get_numeric_encryption_key(encryption_key)), value, encrypt=False) 164 | 165 | 166 | class IPAddressFieldAnonymizer(FieldAnonymizer): 167 | """ 168 | Anonymization for GenericIPAddressField. 169 | 170 | Works for both ipv4 and ipv6. 171 | """ 172 | 173 | empty_values = [None, ''] 174 | 175 | def get_encrypted_value(self, value, encryption_key: str): 176 | return encrypt_ip(encryption_key, value) 177 | 178 | def get_decrypted_value(self, value, encryption_key: str): 179 | return decrypt_ip(encryption_key, value) 180 | 181 | 182 | class IBANFieldAnonymizer(FieldAnonymizer): 183 | """ 184 | Field anonymizer for International Bank Account Number. 185 | """ 186 | 187 | empty_values = [None, ''] 188 | 189 | def get_decrypted_value(self, value: Any, encryption_key: str): 190 | return translate_iban(encryption_key, value) 191 | 192 | def get_encrypted_value(self, value: Any, encryption_key: str): 193 | return translate_iban(encryption_key, value, False) 194 | 195 | 196 | class JSONFieldAnonymizer(FieldAnonymizer): 197 | """ 198 | Anonymization for JSONField. 199 | """ 200 | 201 | empty_values = [None, ''] 202 | 203 | def get_numeric_encryption_key(self, encryption_key: str, value: Union[int, float] = None) -> int: 204 | if value is None: 205 | return numerize_key(encryption_key) 206 | return numerize_key(encryption_key) % 10 ** get_number_guess_len(value) 207 | 208 | def anonymize_json_value(self, value: Union[list, dict, bool, None, str, int, float], 209 | encryption_key: str, 210 | anonymize: bool = True) -> Union[list, dict, bool, None, str, int, float]: 211 | if value is None: 212 | return None 213 | elif type(value) is str: 214 | return translate_text(encryption_key, value, anonymize, JSON_SAFE_CHARS) # type: ignore 215 | elif type(value) is int: 216 | return translate_number(encryption_key, value, anonymize) # type: ignore 217 | elif type(value) is float: 218 | # We cannot safely anonymize floats 219 | return value 220 | elif type(value) is dict: 221 | return {key: self.anonymize_json_value(item, encryption_key, anonymize) for key, item in 222 | value.items()} # type: ignore 223 | elif type(value) is list: 224 | return [self.anonymize_json_value(item, encryption_key, anonymize) for item in value] # type: ignore 225 | elif type(value) is bool and self.get_numeric_encryption_key(encryption_key) % 2 == 0: 226 | return not value 227 | return value 228 | 229 | def get_encrypted_value(self, value, encryption_key: str): 230 | if type(value) not in [dict, list, str]: 231 | raise ValidationError("JSONFieldAnonymizer encountered unknown type of json. " 232 | "Only python dict and list are supported.") 233 | if type(value) == str: 234 | return json.dumps(self.anonymize_json_value(json.loads(value), encryption_key)) 235 | return self.anonymize_json_value(value, encryption_key) 236 | 237 | def get_decrypted_value(self, value, encryption_key: str): 238 | if type(value) not in [dict, list, str]: 239 | raise ValidationError("JSONFieldAnonymizer encountered unknown type of json. " 240 | "Only python dict and list are supported.") 241 | if type(value) == str: 242 | return json.dumps(self.anonymize_json_value(json.loads(value), encryption_key, anonymize=False)) 243 | return self.anonymize_json_value(value, encryption_key, anonymize=False) 244 | 245 | 246 | class StaticValueFieldAnonymizer(FieldAnonymizer): 247 | """ 248 | Static value anonymizer replaces value with defined static value. 249 | """ 250 | 251 | is_reversible = False 252 | empty_values = [None, ''] 253 | 254 | def __init__(self, value: Any, *args, **kwargs) -> None: 255 | super().__init__(*args, **kwargs) 256 | self.value: Any = value 257 | 258 | def get_encrypted_value(self, value: Any, encryption_key: str) -> Any: 259 | return self.value 260 | 261 | 262 | class SiteIDUsernameFieldAnonymizer(FieldAnonymizer): 263 | """ 264 | Encrypts username in format 1:foo@bar.com 265 | """ 266 | 267 | empty_values = [None, ''] 268 | 269 | def get_encrypted_value(self, value, encryption_key: str): 270 | split = value.split(':', 1) 271 | if len(split) == 2: 272 | return f'{split[0]}:{encrypt_email_address(encryption_key, split[1])}' 273 | return encrypt_email_address(encryption_key, value) 274 | 275 | def get_decrypted_value(self, value, encryption_key: str): 276 | split = value.split(':', 1) 277 | if len(split) == 2: 278 | return f'{split[0]}:{decrypt_email_address(encryption_key, split[1])}' 279 | return decrypt_email_address(encryption_key, value) 280 | 281 | 282 | class FileFieldAnonymizer(FieldAnonymizer): 283 | """ 284 | Base class for all FileFieldAnonymizers. 285 | 286 | Overrides ``get_is_value_empty`` to check for files. 287 | """ 288 | 289 | def get_is_value_empty(self, value): 290 | return self.get_ignore_empty_values(value) and not bool(value) 291 | 292 | 293 | class DeleteFileFieldAnonymizer(FileFieldAnonymizer): 294 | """ 295 | One way anonymization of FileField. 296 | """ 297 | 298 | is_reversible = False 299 | 300 | def get_encrypted_value(self, value: Any, encryption_key: str): 301 | value.delete(save=False) 302 | return value 303 | 304 | 305 | class ReplaceFileFieldAnonymizer(FileFieldAnonymizer): 306 | """ 307 | One way anonymization of FileField. 308 | """ 309 | 310 | is_reversible = False 311 | replacement_file: Optional[str] = None 312 | 313 | def __init__(self, replacement_file: Optional[str] = None, *args, **kwargs): 314 | if replacement_file is not None: 315 | self.replacement_file = replacement_file 316 | super().__init__(*args, **kwargs) 317 | 318 | def get_replacement_file(self, file_name): 319 | if self.replacement_file is not None: 320 | return File(open(self.replacement_file, "rb")) 321 | elif getattr(settings, "GDPR_REPLACE_FILE_PATH", None) is not None: 322 | return File(open(getattr(settings, "GDPR_REPLACE_FILE_PATH"), "rb")) 323 | else: 324 | return ContentFile("THIS FILE HAS BEEN ANONYMIZED.") 325 | 326 | def get_encrypted_value(self, value: FieldFile, encryption_key: str): 327 | file_name = value.name 328 | value.delete(save=False) 329 | file = self.get_replacement_file(file_name) 330 | 331 | if func_supports_parameter(value.storage.save, 'max_length'): 332 | value.name = value.storage.save(file_name, file, max_length=value.field.max_length) 333 | else: 334 | # Backwards compatibility removed in Django 1.10 335 | value.name = value.storage.save(file_name, file) 336 | setattr(value.instance, value.field.name, value.name) 337 | 338 | value._size = file.size # Django 1.8 + 1.9 339 | value._committed = True 340 | file.close() 341 | 342 | return value 343 | -------------------------------------------------------------------------------- /gdpr/anonymizers/generic_relation.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from django.apps import apps 4 | from django.contrib.contenttypes.models import ContentType 5 | from django.db.models import Model 6 | 7 | from .base import RelationAnonymizer 8 | 9 | 10 | class ReverseGenericRelationAnonymizer(RelationAnonymizer): 11 | """Defines relation for anonymizer to cope with GenericForeignKey.""" 12 | 13 | app_name: str 14 | model_name: str 15 | content_type_field: str 16 | id_field: str 17 | 18 | def __init__(self, app_name: str, model_name: Optional[str] = None, content_type_field: str = 'content_type', 19 | id_field: str = 'object_id'): 20 | """ 21 | 22 | :param app_name: The name of the app or `.` 23 | :param model_name: The name of the model with GenericRelation 24 | :param content_type_field: The name of the FK to ContentType Model 25 | :param id_field: The id of the related model 26 | """ 27 | if model_name is None: 28 | self.app_name, self.model_name = app_name.split('.') 29 | else: 30 | self.app_name = app_name 31 | self.model_name = model_name 32 | self.content_type_field = content_type_field 33 | self.id_field = id_field 34 | 35 | super().__init__() 36 | 37 | def get_related_objects(self, obj): 38 | return self.model.objects.filter( 39 | **{self.content_type_field: ContentType.objects.get_for_model(obj), self.id_field: obj.pk} 40 | ) 41 | 42 | @property 43 | def model(self): 44 | return apps.get_model(self.app_name, self.model_name) 45 | 46 | 47 | class GenericRelationAnonymizer(RelationAnonymizer): 48 | """Defines relation for anonymizer to cope with GenericForeignKey.""" 49 | 50 | app_name: str 51 | model_name: str 52 | content_object_field: str 53 | 54 | def __init__(self, app_name: str, model_name: Optional[str] = None, content_object_field: str = 'content_object'): 55 | """ 56 | 57 | :param app_name: The name of the app or `.` 58 | :param model_name: The name of the model with GenericRelation 59 | :param content_type_field: The name of the FK to ContentType Model 60 | :param id_field: The id of the related model 61 | """ 62 | if model_name is None: 63 | self.app_name, self.model_name = app_name.split('.') 64 | else: 65 | self.app_name = app_name 66 | self.model_name = model_name 67 | self.content_object_field = content_object_field 68 | 69 | super().__init__() 70 | 71 | def get_related_objects(self, obj): 72 | model: Model = self.model 73 | content_obj = getattr(obj, self.content_object_field, None) 74 | if content_obj is None: 75 | return model.objects.none() 76 | if isinstance(content_obj, model): 77 | return [content_obj] 78 | return model.objects.none() 79 | 80 | @property 81 | def model(self): 82 | return apps.get_model(self.app_name, self.model_name) 83 | -------------------------------------------------------------------------------- /gdpr/anonymizers/gis.py: -------------------------------------------------------------------------------- 1 | """Since django 1.11 djnago-GIS requires GDAL.""" 2 | import logging 3 | from typing import Optional 4 | 5 | from django.core.exceptions import ImproperlyConfigured 6 | 7 | from gdpr.anonymizers.base import NumericFieldAnonymizer 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | def is_gis_installed(): 13 | try: 14 | from django.contrib.gis.geos import Point 15 | return True 16 | except ImproperlyConfigured: 17 | return False 18 | 19 | 20 | if not is_gis_installed(): 21 | logger.warning('Unable to load django GIS. GIS anonymization disabled.') 22 | 23 | 24 | class ExperimentalGISPointFieldAnonymizer(NumericFieldAnonymizer): 25 | """ 26 | Anonymizer for PointField from django-gis. 27 | 28 | Warnings: 29 | May not fully work. Currently works only on positive coordinates. 30 | With ``max_x_range`` and ``max_y_range`` specified. Also anonymization occurs only on the whole part. 31 | """ 32 | 33 | max_x_range: int 34 | max_y_range: int 35 | 36 | def __init__(self, max_x_range: Optional[int] = None, max_y_range: Optional[int] = None, *args, **kwargs): 37 | if max_x_range is not None: 38 | self.max_x_range = max_x_range 39 | elif self.max_x_range is None: 40 | raise ImproperlyConfigured(f'{self.__class__} does not have `max_x_range`.') 41 | if max_y_range is not None: 42 | self.max_y_range = max_y_range 43 | elif self.max_y_range is None: 44 | raise ImproperlyConfigured(f'{self.__class__} does not have `max_y_range`.') 45 | super().__init__(*args, **kwargs) 46 | 47 | def get_encrypted_value(self, value, encryption_key: str): 48 | if not is_gis_installed(): 49 | raise ImproperlyConfigured('Unable to load django GIS.') 50 | from django.contrib.gis.geos import Point 51 | 52 | new_val: Point = Point(value.tuple) 53 | new_val.x = (new_val.x + self.get_numeric_encryption_key(encryption_key, int(new_val.x))) % self.max_x_range 54 | new_val.y = (new_val.y + self.get_numeric_encryption_key(encryption_key, int(new_val.y))) % self.max_y_range 55 | 56 | return new_val 57 | 58 | def get_decrypted_value(self, value, encryption_key: str): 59 | if not is_gis_installed(): 60 | raise ImproperlyConfigured('Unable to load django GIS.') 61 | from django.contrib.gis.geos import Point 62 | 63 | new_val: Point = Point(value.tuple) 64 | new_val.x = (new_val.x - self.get_numeric_encryption_key(encryption_key, int(new_val.x))) % self.max_x_range 65 | new_val.y = (new_val.y - self.get_numeric_encryption_key(encryption_key, int(new_val.y))) % self.max_y_range 66 | 67 | return new_val 68 | -------------------------------------------------------------------------------- /gdpr/anonymizers/hash_fields.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | from typing import Any 3 | 4 | from gdpr.anonymizers.base import FieldAnonymizer 5 | 6 | 7 | class BaseHashTextFieldAnonymizer(FieldAnonymizer): 8 | algorithm: str 9 | is_reversible = False 10 | 11 | def get_encrypted_value(self, value: Any, encryption_key: str): 12 | h = hashlib.new(self.algorithm) 13 | h.update(value.encode('utf-8')) 14 | return h.hexdigest()[:len(value)] if value else value 15 | 16 | 17 | class MD5TextFieldAnonymizer(BaseHashTextFieldAnonymizer): 18 | algorithm = 'md5' 19 | 20 | 21 | class SHA256TextFieldAnonymizer(BaseHashTextFieldAnonymizer): 22 | algorithm = 'sha256' 23 | 24 | 25 | class HashTextFieldAnonymizer(BaseHashTextFieldAnonymizer): 26 | 27 | def __init__(self, algorithm: str, *args, **kwargs): 28 | if algorithm not in hashlib.algorithms_guaranteed: 29 | raise RuntimeError(f'Hash algorithm {algorithm} is not supported by python hashlib.') 30 | self.algorithm = algorithm 31 | 32 | super().__init__(*args, **kwargs) 33 | -------------------------------------------------------------------------------- /gdpr/anonymizers/local/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/druids/django-GDPR/1595a652651100d5a1809c83a1516e5657cf2a6b/gdpr/anonymizers/local/__init__.py -------------------------------------------------------------------------------- /gdpr/anonymizers/local/cs.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import re 3 | from typing import Any, Optional, Tuple, Union 4 | 5 | from django.core.exceptions import ValidationError 6 | 7 | from gdpr.anonymizers.base import FieldAnonymizer, NumericFieldAnonymizer 8 | from gdpr.encryption import LETTERS_UPPER, NUMBERS, decrypt_text, encrypt_text 9 | 10 | PRE_NUM_WEIGHTS = [10, 5, 8, 4, 2, 1] 11 | NUM_WEIGHTS = [6, 3, 7, 9, 10, 5, 8, 4, 2, 1] 12 | 13 | 14 | class CzechAccountNumber: 15 | pre_num: Optional[int] 16 | pre_num_len: Optional[int] 17 | num: int 18 | num_len: int = 10 19 | bank: int 20 | 21 | CZECH_ACCOUNT_RE = re.compile('((?P[0-9]{0,6})-)?(?P[0-9]{1,10})/(?P[0-9]{4})') 22 | 23 | def __init__(self, num: Union[int, str], bank: Union[int, str], pre_num: Optional[Union[int, str]] = None, 24 | num_len: int = 10, pre_num_len: Optional[int] = None, bank_len: int = 4): 25 | self.num = int(num) 26 | self.num_len = num_len 27 | self.bank = int(bank) 28 | self.bank_len = bank_len 29 | self.pre_num_len = pre_num_len 30 | self.pre_num = int(pre_num) if pre_num is not None and pre_num != 0 else None 31 | 32 | def check_account_format(self) -> bool: 33 | pre_num = "%06d" % (self.pre_num or 0) 34 | num = '0' * (10 - len(str(self.num))) + str(self.num) 35 | 36 | pre_num_valid = sum(map(lambda x: x[0] * x[1], zip(map(lambda x: int(x), pre_num), PRE_NUM_WEIGHTS))) % 11 == 0 37 | num_valid = sum(map(lambda x: x[0] * x[1], zip(map(lambda x: int(x), num), NUM_WEIGHTS))) % 11 == 0 38 | 39 | return num_valid and pre_num_valid 40 | 41 | def _brute_force_next(self): 42 | self.num += 1 43 | if len(str(self.num)) > 10: 44 | self.num = 0 45 | while not self.check_account_format(): 46 | if len(str(self.num)) > 10: 47 | self.num = 0 48 | self.num += 1 49 | 50 | def brute_force_next(self, n: int): 51 | for i in range(n): 52 | self._brute_force_next() 53 | 54 | return self # allow chaining 55 | 56 | def _brute_force_prev(self): 57 | self.num -= 1 58 | if self.num <= 0: 59 | self.num = int('9' * 10) 60 | while not self.check_account_format(): 61 | if self.num <= 0: 62 | self.num = int('9' * 10) 63 | self.num -= 1 64 | 65 | def brute_force_prev(self, n: int): 66 | for i in range(n): 67 | self._brute_force_prev() 68 | 69 | return self # allow chaining 70 | 71 | @classmethod 72 | def parse(cls, value: str) -> "CzechAccountNumber": 73 | """ 74 | :param value: 75 | :return: AccountNumber(predcisli)-(cislo)/(kod_banky) 76 | """ 77 | account = re.match(cls.CZECH_ACCOUNT_RE, value) 78 | 79 | if account is None: 80 | raise ValidationError(f'Str \'{value}\' does not appear to be czech account number.') 81 | 82 | pre_num = account.group('pre_num') 83 | num = account.group('num') 84 | bank_code = account.group('bank_code') 85 | return cls(pre_num=pre_num, pre_num_len=len(pre_num or ""), num=num, num_len=len(num), 86 | bank=bank_code, bank_len=len(bank_code)) 87 | 88 | def __str__(self): 89 | return ((f'{str(self.pre_num).rjust(self.pre_num_len, "0") if self.pre_num_len else self.pre_num}-' 90 | if self.pre_num else '' 91 | ) + f'{str(self.num).rjust(self.num_len, "0")}/{str(self.bank).rjust(self.bank_len, "0")}') 92 | 93 | 94 | class CzechIBAN(CzechAccountNumber): 95 | has_spaces = False 96 | control_code: int 97 | CZECH_IBAN_RE = re.compile( 98 | 'CZ(?P[0-9]{2}) ?(?P[0-9]{4}) ?' 99 | '(?P[0-9]{4} ?[0-9]{2})(?P[0-9]{2} ?[0-9]{4} ?[0-9]{4})', 100 | ) 101 | 102 | def __init__(self, *args, has_spaces: bool = False, control_code: Optional[int] = None, **kwargs): 103 | super().__init__(*args, **kwargs) 104 | self.has_spaces = has_spaces 105 | if control_code is not None: 106 | self.control_code = control_code 107 | else: 108 | self._update_control_code() 109 | 110 | @classmethod 111 | def from_account(cls, value: "CzechAccountNumber") -> "CzechIBAN": 112 | return cls(num=value.num, bank=value.bank, pre_num=value.num) 113 | 114 | @classmethod 115 | def parse(cls, value: str) -> "CzechIBAN": 116 | """ 117 | :param value: 118 | :return: AccountNumber(predcisli)-(cislo)/(kod_banky) 119 | """ 120 | account = re.match(cls.CZECH_IBAN_RE, value) 121 | 122 | if account: 123 | control_code = account.group('control_code').upper() 124 | bank_code = account.group('bank_code').upper() 125 | pre_num = account.group('pre_num').replace(' ', '').upper() 126 | num = account.group('num').replace(' ', '').upper() 127 | 128 | if all([all([i in (LETTERS_UPPER + NUMBERS) for i in control_code]), 129 | all([i in NUMBERS for i in bank_code]), 130 | all([i in NUMBERS for i in pre_num]), 131 | all([i in NUMBERS for i in num])]): 132 | return cls( 133 | control_code=int(control_code), has_spaces=' ' in value, 134 | pre_num=int(pre_num), num=int(num), bank=int(bank_code)) 135 | 136 | raise ValidationError(f'IBAN \'{value}\' does not appear to be czech IBAN.') 137 | 138 | def _to_str(self, spaces: Optional[bool] = None): 139 | pre_num = str(self.pre_num or 0).rjust(6, '0') 140 | num = str(self.num).rjust(10, '0') 141 | out = (f'CZ{str(self.control_code).rjust(2, "0")} {str(self.bank).rjust(4, "0")} {pre_num[:4]} ' 142 | f'{pre_num[4:]}{num[:2]} {num[2:6]} {num[6:]}') 143 | if (spaces is None and self.has_spaces) or spaces: 144 | return out 145 | return out.replace(' ', '') 146 | 147 | def __str__(self) -> str: 148 | return self._to_str() 149 | 150 | def _to_int(self) -> int: 151 | """ 152 | Convert IBAN to numeric value (ISO 7064) to be able to calculate `control_code`and check if IBAN is valid. 153 | """ 154 | iban = self._to_str(spaces=False) 155 | return int( 156 | "".join([i if not ord('A') <= ord(i) <= ord('Z') else str(ord(i) - 55) for i in iban[4:] + iban[:4]])) 157 | 158 | def check_iban_format(self) -> bool: 159 | return self._to_int() % 97 == 1 160 | 161 | def _update_control_code(self): 162 | """ 163 | Calculate new control code 164 | """ 165 | self.control_code = 0 166 | self.control_code = 98 - (self._to_int() % 97) 167 | 168 | def brute_force_next(self, n: int): 169 | self.control_code = 0 170 | super().brute_force_next(n) 171 | self._update_control_code() 172 | 173 | return self # allow chaining 174 | 175 | def brute_force_prev(self, n: int): 176 | self.control_code = 0 177 | super().brute_force_prev(n) 178 | self._update_control_code() 179 | 180 | return self # allow chaining 181 | 182 | 183 | class CzechPersonalID: 184 | date: datetime.date 185 | day_index: int 186 | control_number: Optional[int] = None 187 | 188 | day_offset: bool = False 189 | is_male: bool 190 | is_extra: bool = False 191 | has_slash: bool = True 192 | 193 | CZECH_PERSONAL_ID_RE = re.compile( 194 | r'^(?P\d{2})(?P\d{2})(?P\d{2})/?(?P\d{3})(?P\d)?$') 195 | 196 | def __init__(self, date: datetime.date, is_male: bool, day_index: int, control_number: Optional[int] = None, 197 | is_extra: bool = False, day_offset: bool = False, has_slash: bool = True): 198 | """ 199 | 200 | Args: 201 | date: The ``datetime.date`` object of the Personal ID 202 | is_male: Is the person male? (Month + 50) 203 | day_index: The index of the person in the given day 204 | control_number: The last digit in personal id 205 | is_extra: Did we run out of IDs on that day? (Month + 20) 206 | day_offset: In some rare cases the days may be offset by 50 is this the case? 207 | has_slash: Show ``/`` in ``__str__`` representation 208 | 209 | """ 210 | self.day_index = day_index 211 | self.date = date 212 | self.is_male = is_male 213 | self.control_number = control_number 214 | self.is_extra = is_extra 215 | self.day_offset = day_offset 216 | self.has_slash = has_slash 217 | 218 | @property 219 | def is_pre_1954(self): 220 | return self.date.year < 1954 221 | 222 | def __str__(self): 223 | month = self.date.month 224 | if not self.is_male: 225 | month += 50 226 | if self.is_extra: 227 | month += 20 228 | return (f'{str(self.date.year)[-2:]}{"%02d" % month}' 229 | f'{"%02d" % (self.date.day if not self.day_offset else self.date.day + 50)}' 230 | f'{"/" if self.has_slash else ""}{("%03d" % self.day_index)}' 231 | f'{self.control_number if self.control_number is not None else ""}') 232 | 233 | @classmethod 234 | def parse(cls, value) -> "CzechPersonalID": 235 | personal_id = re.match(cls.CZECH_PERSONAL_ID_RE, value) 236 | 237 | if personal_id is None: 238 | raise ValidationError(f'Str \'{value}\' does not appear to be czech personal id.') 239 | 240 | year = int(personal_id.group('year')) 241 | month = int(personal_id.group('month')) 242 | day = int(personal_id.group('day')) 243 | day_index = int(personal_id.group('day_index')) 244 | key = int(personal_id.group('key')) if personal_id.group('key') is not None else None 245 | 246 | pre_1954 = len(value.replace('/', '')) == 9 247 | is_male = month < 50 248 | is_extra = month > 12 if is_male else month > 62 249 | is_day_offset = day > 50 250 | full_year = (2000 if year < 54 and not pre_1954 else 1900) + year 251 | if is_male and is_extra and full_year > 2003: 252 | month -= 20 253 | elif not is_male and is_extra and full_year > 2003: 254 | month -= 70 255 | elif not is_male: 256 | month -= 50 257 | if not (1 <= month <= 12): 258 | raise ValidationError(f'Str \'{value}\' does not appear to be czech personal id.') 259 | 260 | if is_day_offset: 261 | day -= 50 262 | 263 | return cls( 264 | date=datetime.date(year=full_year, month=month, day=day), 265 | is_male=is_male, 266 | day_index=day_index, 267 | control_number=key, 268 | is_extra=is_extra, 269 | day_offset=is_day_offset, 270 | has_slash='/' in value, 271 | ) 272 | 273 | def check_format(self): 274 | 275 | # Three digits for verification number were used until 1. january 1954 276 | if not self.is_pre_1954: 277 | """ 278 | Fourth digit has been added since 1. January 1954. 279 | It is modulo of dividing birth number and verification number by 11. 280 | If the modulo were 10, the last number was 0 (and therefore, the whole 281 | birth number weren't dividable by 11. These number are no longer used (since 1985) 282 | and condition 'modulo == 10' can be removed some years after 2085. 283 | """ 284 | 285 | modulo = int(str(self).replace('/', '')[:-1]) % 11 286 | 287 | if (modulo != self.control_number) and (modulo != 10 or self.control_number != 0): 288 | return False 289 | 290 | return True 291 | 292 | def brute_force_control_number(self): 293 | self.control_number = 0 294 | while not self.check_format(): 295 | self.control_number += 1 296 | 297 | def encrypt(self, numeric_key): 298 | numeric_key %= 365 299 | 300 | if numeric_key % 2 == 0: 301 | self.is_male = not self.is_male 302 | 303 | self.date -= datetime.timedelta(days=numeric_key + 1) 304 | self.day_index = int(encrypt_text(str(numeric_key), str(self.day_index), NUMBERS)) 305 | 306 | if self.is_pre_1954: 307 | self.control_number = None 308 | else: 309 | self.brute_force_control_number() 310 | 311 | return self # Enable chaining 312 | 313 | def decrypt(self, numeric_key): 314 | numeric_key %= 365 315 | 316 | if numeric_key % 2 == 0: 317 | self.is_male = not self.is_male 318 | 319 | self.date += datetime.timedelta(days=numeric_key + 1) 320 | self.day_index = int(decrypt_text(str(numeric_key), str(self.day_index), NUMBERS)) 321 | 322 | if self.is_pre_1954: 323 | self.control_number = None 324 | else: 325 | self.brute_force_control_number() 326 | 327 | return self # Enable chaining 328 | 329 | 330 | class CzechAccountNumberFieldAnonymizer(NumericFieldAnonymizer): 331 | """ 332 | Anonymization for czech account number. 333 | 334 | Setting `use_smart_method=True` retains valid format for encrypted value using this 335 | have significant effect on performance. 336 | """ 337 | use_smart_method = False 338 | max_anonymization_range = 10000 339 | 340 | def __init__(self, *args, use_smart_method=False, **kwargs): 341 | self.use_smart_method = use_smart_method 342 | super().__init__(*args, **kwargs) 343 | 344 | def get_encrypted_value(self, value, encryption_key: str): 345 | account = CzechAccountNumber.parse(value) 346 | 347 | if self.use_smart_method and account.check_account_format(): 348 | return str(account.brute_force_next(self.get_numeric_encryption_key(encryption_key))) 349 | 350 | account.num = int(encrypt_text(encryption_key, str(account.num), NUMBERS)) 351 | 352 | return str(account) 353 | 354 | def get_decrypted_value(self, value: Any, encryption_key: str): 355 | account = CzechAccountNumber.parse(value) 356 | 357 | if self.use_smart_method and account.check_account_format(): 358 | return str(account.brute_force_prev(self.get_numeric_encryption_key(encryption_key))) 359 | 360 | account.num = int(decrypt_text(encryption_key, str(account.num), NUMBERS)) 361 | 362 | return str(account) 363 | 364 | 365 | class CzechIBANSmartFieldAnonymizer(NumericFieldAnonymizer): 366 | max_anonymization_range = 10000 367 | 368 | def get_encrypted_value(self, value: Any, encryption_key: str): 369 | iban = CzechIBAN.parse(value) 370 | if not iban.check_iban_format(): 371 | raise ValidationError(f'IBAN \'{value}\' does not appear to be valid czech IBAN.') 372 | return str(iban.brute_force_next(self.get_numeric_encryption_key(encryption_key))) 373 | 374 | def get_decrypted_value(self, value: Any, encryption_key: str): 375 | iban = CzechIBAN.parse(value) 376 | if not iban.check_iban_format(): 377 | raise ValidationError(f'IBAN \'{value}\' does not appear to be valid czech IBAN.') 378 | return str(iban.brute_force_prev(self.get_numeric_encryption_key(encryption_key))) 379 | 380 | 381 | class CzechPhoneNumberFieldAnonymizer(FieldAnonymizer): 382 | 383 | def split_phone_number(self, value: str) -> Tuple[str, str]: 384 | """ 385 | Split phone number in international format into area code and number 386 | """ 387 | area_code = value[:-9] 388 | phone_number = value[-9:] 389 | return area_code, phone_number 390 | 391 | def get_encrypted_value(self, value: str, encryption_key: str): 392 | area_code, phone_number = self.split_phone_number(value) 393 | encrypted_phone_number = encrypt_text(encryption_key, phone_number[3:], NUMBERS) 394 | return f'{area_code}{phone_number[:3]}{encrypted_phone_number}' 395 | 396 | def get_decrypted_value(self, value: str, encryption_key: str): 397 | area_code, phone_number = self.split_phone_number(value) 398 | encrypted_phone_number = decrypt_text(encryption_key, phone_number[3:], NUMBERS) 399 | return f'{area_code}{phone_number[:3]}{encrypted_phone_number}' 400 | 401 | 402 | class CzechIDCardFieldAnonymizer(NumericFieldAnonymizer): 403 | max_anonymization_range = int("9" * 9) 404 | 405 | def get_encrypted_value(self, value: str, encryption_key: str): 406 | return f"{value[0]}{encrypt_text(str(self.get_numeric_encryption_key(encryption_key)), value[1:], NUMBERS)}" 407 | 408 | def get_decrypted_value(self, value: str, encryption_key: str): 409 | return f"{value[0]}{decrypt_text(str(self.get_numeric_encryption_key(encryption_key)), value[1:], NUMBERS)}" 410 | 411 | 412 | class CzechPersonalIDSmartFieldAnonymizer(NumericFieldAnonymizer): 413 | max_anonymization_range = 365 414 | 415 | def get_encrypted_value(self, value: str, encryption_key: str): 416 | return str(CzechPersonalID.parse(value).encrypt(self.get_numeric_encryption_key(encryption_key))) 417 | 418 | def get_decrypted_value(self, value: str, encryption_key: str): 419 | return str(CzechPersonalID.parse(value).decrypt(self.get_numeric_encryption_key(encryption_key))) 420 | -------------------------------------------------------------------------------- /gdpr/enums.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext_lazy as _ 2 | 3 | from enumfields import Choice, IntegerChoicesEnum 4 | 5 | 6 | class LegalReasonState(IntegerChoicesEnum): 7 | 8 | ACTIVE = Choice(1, _('Active')) 9 | EXPIRED = Choice(2, _('Expired')) 10 | DEACTIVATED = Choice(3, _('Deactivated')) 11 | -------------------------------------------------------------------------------- /gdpr/fields.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Any, Dict, List, Tuple, Type, Union 2 | 3 | from django.db.models import Model 4 | 5 | from gdpr.loading import anonymizer_register 6 | 7 | FieldMatrix = Union[str, Tuple[Any, ...]] 8 | FieldList = Union[List[str], str] 9 | RelatedFieldDict = Dict[str, "Fields"] 10 | 11 | if TYPE_CHECKING: 12 | from gdpr.anonymizers import ModelAnonymizer 13 | 14 | 15 | class Fields: 16 | local_fields: FieldList 17 | related_fields: RelatedFieldDict 18 | anonymizer: "ModelAnonymizer" 19 | model: Type[Model] 20 | 21 | def __init__(self, fields: FieldMatrix, model: Type[Model], anonymizer_instance: "ModelAnonymizer" = None): 22 | self.model = model 23 | self.anonymizer = anonymizer_register[self.model]() if anonymizer_instance is None else anonymizer_instance 24 | self.local_fields = self.parse_local_fields(fields) 25 | self.related_fields = self.parse_related_fields(fields) 26 | 27 | def parse_local_fields(self, fields: FieldMatrix) -> FieldList: 28 | """Get Iterable of local fields from fields matrix.""" 29 | if fields == '__ALL__' or ('__ALL__' in fields and type(fields) != str): 30 | return list(self.anonymizer.keys()) 31 | 32 | return [field for field in fields if type(field) == str] 33 | 34 | def parse_related_fields(self, fields: FieldMatrix) -> RelatedFieldDict: 35 | """Get Dictionary of related fields from fields matrix.""" 36 | out_dict = {} 37 | for name, related_fields in [field_tuple for field_tuple in fields if isinstance(field_tuple, (list, tuple))]: 38 | out_dict[name] = Fields( 39 | related_fields, 40 | self.anonymizer.get_related_model(name), 41 | anonymizer_instance=self.anonymizer.get_related_model_anonymizer_none(name) 42 | ) 43 | 44 | return out_dict 45 | 46 | def get_tuple(self) -> FieldMatrix: 47 | return (*self.local_fields, *[(name, fields.get_tuple()) for name, fields in self.related_fields.items()]) 48 | 49 | def __len__(self): 50 | return len(self.local_fields) + len(self.related_fields) 51 | 52 | def __isub__(self, other: "Fields") -> "Fields": 53 | self.local_fields = [field for field in self.local_fields if field not in other.local_fields] 54 | 55 | for name, related_fields in self.related_fields.items(): 56 | if name in other.related_fields: 57 | related_fields -= other.related_fields[name] 58 | 59 | for name in list(self.related_fields.keys()): 60 | if len(self.related_fields[name]) == 0: 61 | del self.related_fields[name] 62 | 63 | return self 64 | -------------------------------------------------------------------------------- /gdpr/ipcypher.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implementation of ipcipher. 3 | 4 | https://github.com/PowerDNS/ipcipher 5 | 6 | IPv4 took from https://github.com/veorq/ipcrypt/blob/939549e3f542c7ae9eb1aec96164f19b5f9fc46c/ipcrypt.py under CC0 7 | """ 8 | 9 | # type: ignore 10 | # flake8: noqa 11 | from hashlib import pbkdf2_hmac 12 | from ipaddress import IPv4Address, IPv6Address, ip_address 13 | from typing import Union 14 | 15 | from pyaes import AESModeOfOperationECB 16 | 17 | 18 | __all__ = ['derive_key', 'encrypt_ipv4', 'decrypt_ipv4', 'encrypt_ipv6', 'decrypt_ipv6', 'encrypt_ip', 'decrypt_ip'] 19 | 20 | IPv4Type = Union[str, IPv4Address] 21 | IPv6Type = Union[str, IPv6Address] 22 | IPType = Union[IPv4Type, IPv6Type] 23 | 24 | 25 | def derive_key(key: str): 26 | """ 27 | PBKDF2(SHA1, Password, 'ipcipheripcipher', 50000, 16) 28 | """ 29 | 30 | return pbkdf2_hmac('sha1', bytes(key, encoding="utf-8"), bytes('ipcipheripcipher', encoding='utf-8'), 50000, 16) 31 | 32 | 33 | def rotl(b, r): 34 | return ((b << r) & 0xff) | (b >> (8 - r)) 35 | 36 | 37 | def permute_fwd(state): 38 | (b0, b1, b2, b3) = state 39 | b0 += b1 40 | b2 += b3 41 | b0 &= 0xff 42 | b2 &= 0xff 43 | b1 = rotl(b1, 2) 44 | b3 = rotl(b3, 5) 45 | b1 ^= b0 46 | b3 ^= b2 47 | b0 = rotl(b0, 4) 48 | b0 += b3 49 | b2 += b1 50 | b0 &= 0xff 51 | b2 &= 0xff 52 | b1 = rotl(b1, 3) 53 | b3 = rotl(b3, 7) 54 | b1 ^= b2 55 | b3 ^= b0 56 | b2 = rotl(b2, 4) 57 | return b0, b1, b2, b3 58 | 59 | 60 | def permute_bwd(state): 61 | (b0, b1, b2, b3) = state 62 | b2 = rotl(b2, 4) 63 | b1 ^= b2 64 | b3 ^= b0 65 | b1 = rotl(b1, 5) 66 | b3 = rotl(b3, 1) 67 | b0 -= b3 68 | b2 -= b1 69 | b0 &= 0xff 70 | b2 &= 0xff 71 | b0 = rotl(b0, 4) 72 | b1 ^= b0 73 | b3 ^= b2 74 | b1 = rotl(b1, 6) 75 | b3 = rotl(b3, 3) 76 | b0 -= b1 77 | b2 -= b3 78 | b0 &= 0xff 79 | b2 &= 0xff 80 | return b0, b1, b2, b3 81 | 82 | 83 | def xor4(x, y): 84 | return [(x[i] ^ y[i]) & 0xff for i in (0, 1, 2, 3)] 85 | 86 | 87 | def encrypt_ipv4_bytes_key(key: bytes, ip: IPv4Address) -> str: 88 | """16-byte key, ip string like '192.168.1.2'""" 89 | if ip.version != 4: 90 | raise ValueError('IPv6 supplied to IPv4 function.') 91 | k = [int(x) for x in key] 92 | try: 93 | state = [int(x) for x in ip.exploded.split('.')] 94 | except ValueError: 95 | raise 96 | try: 97 | state = xor4(state, k[:4]) 98 | state = permute_fwd(state) 99 | state = xor4(state, k[4:8]) 100 | state = permute_fwd(state) 101 | state = xor4(state, k[8:12]) 102 | state = permute_fwd(state) 103 | state = xor4(state, k[12:16]) 104 | except IndexError: 105 | raise 106 | return '.'.join(str(x) for x in state) 107 | 108 | 109 | def encrypt_ipv4(key: str, ip: IPv4Type) -> str: 110 | if not isinstance(ip, IPv4Address): 111 | ip = ip_address(ip) 112 | return encrypt_ipv4_bytes_key(derive_key(key), ip) # type: ignore 113 | 114 | 115 | def decrypt_ipv4_bytes_key(key: bytes, ip: IPv4Address) -> str: 116 | """16-byte key, encrypted ip string like '215.51.199.127'""" 117 | if ip.version != 4: 118 | raise ValueError('IPv6 supplied to IPv4 function.') 119 | k = [x for x in key] 120 | try: 121 | state = [int(x) for x in ip.exploded.split('.')] 122 | except ValueError: 123 | raise 124 | try: 125 | state = xor4(state, k[12:16]) 126 | state = permute_bwd(state) 127 | state = xor4(state, k[8:12]) 128 | state = permute_bwd(state) 129 | state = xor4(state, k[4:8]) 130 | state = permute_bwd(state) 131 | state = xor4(state, k[:4]) 132 | except IndexError: 133 | raise 134 | return '.'.join(str(x) for x in state) 135 | 136 | 137 | def decrypt_ipv4(key: str, ip: IPv4Type) -> str: 138 | if not isinstance(ip, IPv4Address): 139 | ip = ip_address(ip) 140 | return decrypt_ipv4_bytes_key(derive_key(key), ip) 141 | 142 | 143 | def encrypt_ipv6_bytes_key(key: bytes, ip: IPv6Address) -> IPv6Address: 144 | return IPv6Address(AESModeOfOperationECB(key).encrypt(ip.packed)) 145 | 146 | 147 | def encrypt_ipv6(key: str, ip: IPv6Type) -> str: 148 | if not isinstance(ip, IPv6Address): 149 | ip = ip_address(ip) 150 | return encrypt_ipv6_bytes_key(derive_key(key), ip).compressed 151 | 152 | 153 | def decrypt_ipv6_bytes_key(key: bytes, ip: IPv6Address) -> IPv6Address: 154 | return IPv6Address(AESModeOfOperationECB(key).decrypt(ip.packed)) 155 | 156 | 157 | def decrypt_ipv6(key: str, ip: IPv6Type) -> str: 158 | if not isinstance(ip, IPv6Address): 159 | ip = ip_address(ip) 160 | return decrypt_ipv6_bytes_key(derive_key(key), ip).compressed 161 | 162 | 163 | def encrypt_ip(key: str, ip: IPType) -> str: 164 | if not isinstance(ip, IPv6Address) and not isinstance(ip, IPv4Address): 165 | ip = ip_address(ip) 166 | if ip.version == 4: 167 | return encrypt_ipv4(key, ip) 168 | elif ip.version == 6: 169 | return encrypt_ipv6(key, ip) 170 | 171 | 172 | def decrypt_ip(key: str, ip: IPType) -> str: 173 | if not isinstance(ip, IPv6Address) and not isinstance(ip, IPv4Address): 174 | ip = ip_address(ip) 175 | if ip.version == 4: 176 | return decrypt_ipv4(key, ip) 177 | elif ip.version == 6: 178 | return decrypt_ipv6(key, ip) 179 | -------------------------------------------------------------------------------- /gdpr/loading.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict, _OrderedDictItemsView, _OrderedDictKeysView, _OrderedDictValuesView 2 | from importlib import import_module 3 | from typing import TYPE_CHECKING, Any, Generic, Iterator, Optional, Type, TypeVar, Union 4 | 5 | from django.apps import apps 6 | from django.conf import settings 7 | from django.core.exceptions import ImproperlyConfigured 8 | from django.db.models import Model 9 | from django.utils.encoding import force_text 10 | 11 | from .utils import str_to_class 12 | 13 | if TYPE_CHECKING: 14 | from gdpr.anonymizers import ModelAnonymizer 15 | from gdpr.purposes import AbstractPurpose 16 | 17 | 18 | class BaseLoader: 19 | """Base class for all loaders.""" 20 | 21 | def import_modules(self) -> None: 22 | raise NotImplementedError 23 | 24 | 25 | class AppLoader(BaseLoader): 26 | """Scan all installed apps for `module_name` module.""" 27 | 28 | module_name: str 29 | 30 | def import_modules(self) -> None: 31 | for app in apps.get_app_configs(): 32 | if app.name == 'gdpr': 33 | continue 34 | try: 35 | import_module(f'{app.name}.{self.module_name}') 36 | except ImportError as ex: 37 | if force_text(ex) != f'No module named \'{app.name}.{self.module_name}\'': 38 | raise ex 39 | 40 | 41 | class SettingsListLoader(BaseLoader): 42 | """Import all modules from list `list_name` in settings.""" 43 | 44 | list_name: str 45 | 46 | def import_modules(self): 47 | if not hasattr(settings, self.list_name): 48 | raise ImproperlyConfigured(f'settings.{self.list_name} not found.') 49 | modules_list = getattr(settings, self.list_name) 50 | if type(modules_list) in [list, tuple]: 51 | for i in modules_list: 52 | import_module(i) 53 | else: 54 | raise ImproperlyConfigured(f'settings.{self.list_name} have incorrect type {str(type(modules_list))}.') 55 | 56 | 57 | class SettingsListAnonymizerLoader(SettingsListLoader): 58 | """Load all anonymizers from settings.GDPR_ANONYMIZERS_LIST list.""" 59 | 60 | list_name = 'GDPR_ANONYMIZERS_LIST' 61 | 62 | 63 | class SettingsListPurposesLoader(SettingsListLoader): 64 | """Load all purposes from settings.GDPR_PURPOSES_LIST list.""" 65 | 66 | list_name = 'GDPR_PURPOSES_LIST' 67 | 68 | 69 | class AppAnonymizerLoader(AppLoader): 70 | """Scan all installed apps for anonymizers module which should contain anonymizers.""" 71 | 72 | module_name = 'anonymizers' 73 | 74 | 75 | class AppPurposesLoader(AppLoader): 76 | """Scan all installed apps for purposes module which should contain purposes.""" 77 | 78 | module_name = 'purposes' 79 | 80 | 81 | K = TypeVar('K') 82 | V = TypeVar('V') 83 | 84 | 85 | class BaseRegister(Generic[K, V]): 86 | """Base class for all registers.""" 87 | 88 | _is_import_done = False 89 | register_dict: "OrderedDict[K, V]" 90 | loaders_settings: str 91 | default_loader: Optional[str] 92 | 93 | def __init__(self): 94 | self.register_dict = OrderedDict() 95 | 96 | def register(self, key: K, object_class: V) -> None: 97 | self.register_dict[key] = object_class 98 | 99 | def _import_objects(self) -> None: 100 | default_loader = [self.default_loader] if self.default_loader else [] 101 | for loader_path in getattr(settings, self.loaders_settings, default_loader): 102 | if isinstance(loader_path, (list, tuple)): 103 | for path in loader_path: 104 | import_module(path) 105 | else: 106 | str_to_class(loader_path)().import_modules() 107 | 108 | def _import_objects_once(self) -> None: 109 | if self._is_import_done: 110 | return 111 | self._is_import_done = True 112 | self._import_objects() 113 | 114 | def __iter__(self) -> Iterator[V]: 115 | self._import_objects_once() 116 | 117 | for o in self.register_dict.values(): 118 | yield o 119 | 120 | def __contains__(self, key: K) -> bool: 121 | self._import_objects_once() 122 | 123 | return key in self.register_dict.keys() 124 | 125 | def __getitem__(self, key: K) -> V: 126 | self._import_objects_once() 127 | 128 | return self.register_dict[key] 129 | 130 | def keys(self) -> "_OrderedDictKeysView[K]": 131 | self._import_objects_once() 132 | 133 | return self.register_dict.keys() 134 | 135 | def items(self) -> "_OrderedDictItemsView[K, V]": 136 | self._import_objects_once() 137 | 138 | return self.register_dict.items() 139 | 140 | def values(self) -> "_OrderedDictValuesView[V]": 141 | self._import_objects_once() 142 | 143 | return self.register_dict.values() 144 | 145 | def get(self, *args, **kwargs) -> Union[V, Any]: 146 | self._import_objects_once() 147 | 148 | return self.register_dict.get(*args, **kwargs) 149 | 150 | 151 | class AnonymizersRegister(BaseRegister[Model, Type["ModelAnonymizer"]]): 152 | """ 153 | AnonymizersRegister is storage for found anonymizer classes. 154 | """ 155 | 156 | default_loader = 'gdpr.loading.AppAnonymizerLoader' 157 | loaders_settings = 'ANONYMIZATION_LOADERS' 158 | 159 | 160 | class PurposesRegister(BaseRegister[str, Type["AbstractPurpose"]]): 161 | """ 162 | PurposesRegister is storage for found purpose classes. 163 | """ 164 | 165 | default_loader = 'gdpr.loading.AppPurposesLoader' 166 | loaders_settings = 'PURPOSE_LOADERS' 167 | 168 | 169 | anonymizer_register = AnonymizersRegister() 170 | purpose_register = PurposesRegister() 171 | -------------------------------------------------------------------------------- /gdpr/locale/cs/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/druids/django-GDPR/1595a652651100d5a1809c83a1516e5657cf2a6b/gdpr/locale/cs/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /gdpr/locale/cs/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2020-02-18 17:28+0100\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=4; plural=(n == 1 && n % 1 == 0) ? 0 : (n >= 2 && n " 20 | "<= 4 && n % 1 == 0) ? 1: (n % 1 != 0 ) ? 2 : 3;\n" 21 | 22 | #. Translators: You can add special characters of your language at the end 23 | #: encryption.py:21 24 | msgid "" 25 | " !\"#$%&'()*+,-./0123456789:;<=>?" 26 | "@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" 27 | msgstr "" 28 | " !\"#$%&'()*+,-./0123456789:;<=>?" 29 | "@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" 30 | 31 | #. Translators: You can add special characters of your language at the end 32 | #: encryption.py:23 33 | msgid "" 34 | " !#$%&()*+,-./0123456789:;<=>?" 35 | "@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^_`abcdefghijklmnopqrstuvwxyz{|}~" 36 | msgstr "" 37 | " !#$%&()*+,-./0123456789:;<=>?" 38 | "@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^_`abcdefghijklmnopqrstuvwxyz{|}~" 39 | 40 | #: enums.py:8 41 | msgid "Active" 42 | msgstr "Aktivní" 43 | 44 | #: enums.py:9 45 | msgid "Expired" 46 | msgstr "Expirovaný" 47 | 48 | #: enums.py:10 49 | msgid "Deactivated" 50 | msgstr "Deaktivovaný" 51 | 52 | #: models.py:174 53 | msgid "issued at" 54 | msgstr "přiřazen" 55 | 56 | #: models.py:179 57 | msgid "expires at" 58 | msgstr "datum expirace" 59 | 60 | #: models.py:185 61 | msgid "tag" 62 | msgstr "tag" 63 | 64 | #: models.py:191 65 | msgid "state" 66 | msgstr "stav" 67 | 68 | #: models.py:198 69 | msgid "purpose" 70 | msgstr "účel" 71 | 72 | #: models.py:206 73 | msgid "source object content type" 74 | msgstr "typ zrojového objektu" 75 | 76 | #: models.py:212 77 | msgid "source object ID" 78 | msgstr "ID zdrojového objektu" 79 | 80 | #: models.py:221 models.py:267 81 | msgid "legal reason" 82 | msgstr "legální účel držení dat" 83 | 84 | #: models.py:222 85 | msgid "legal reasons" 86 | msgstr "legální účely držení dat" 87 | 88 | #: models.py:275 models.py:321 89 | msgid "related object content type" 90 | msgstr "typ vztaženého objektu" 91 | 92 | #: models.py:281 models.py:327 93 | msgid "related object ID" 94 | msgstr "ID vztaženého objektu" 95 | 96 | #: models.py:291 97 | msgid "legal reason related object" 98 | msgstr "vztažený objekt legálního účelu držení dat" 99 | 100 | #: models.py:292 101 | msgid "legal reasons related objects" 102 | msgstr "vztažené objekty legálních účelů držení dat" 103 | 104 | #: models.py:314 105 | msgid "anonymized field name" 106 | msgstr "název anonymizovaného pole" 107 | 108 | #: models.py:335 109 | msgid "is active" 110 | msgstr "aktivní" 111 | 112 | #: models.py:340 113 | msgid "expired reason" 114 | msgstr "důvod expirace" 115 | 116 | #: models.py:347 models.py:348 117 | msgid "anonymized data" 118 | msgstr "anonymizovaná data" 119 | -------------------------------------------------------------------------------- /gdpr/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/druids/django-GDPR/1595a652651100d5a1809c83a1516e5657cf2a6b/gdpr/management/__init__.py -------------------------------------------------------------------------------- /gdpr/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/druids/django-GDPR/1595a652651100d5a1809c83a1516e5657cf2a6b/gdpr/management/commands/__init__.py -------------------------------------------------------------------------------- /gdpr/management/commands/anonymize_data.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | import pyprind 4 | from django.core.management.base import BaseCommand 5 | from utils import chunked_iterator, chunked_queryset_iterator 6 | from utils.commands import ProgressBarStream 7 | 8 | from gdpr.anonymizers import DeleteModelAnonymizer 9 | from gdpr.loading import anonymizer_register 10 | 11 | 12 | class Command(BaseCommand): 13 | help = 'Anonymize database data according to defined anonymizers in applications.' 14 | 15 | def add_arguments(self, parser): 16 | parser.add_argument('--models', type=str, action='store', dest='models', 17 | help='name of the anonymized models ("app_name.model_name") separated by a comma.') 18 | 19 | def _anonymize_by_qs(self, obj_anonymizer, qs): 20 | bar = pyprind.ProgBar( 21 | max(math.ceil(qs.count() // obj_anonymizer.chunk_size), 1), 22 | title='Anonymize model {}'.format(self._get_full_model_name(qs.model)), 23 | stream=ProgressBarStream(self.stdout) 24 | ) 25 | for batch_qs in chunked_queryset_iterator(qs, obj_anonymizer.chunk_size, delete_qs=isinstance( 26 | obj_anonymizer, DeleteModelAnonymizer)): 27 | obj_anonymizer().anonymize_qs(batch_qs) 28 | bar.update() 29 | 30 | def _anonymize_by_obj(self, obj_anonymizer, qs): 31 | bar = pyprind.ProgBar( 32 | qs.count(), 33 | title='Anonymize model {}'.format(self._get_full_model_name(qs.model)), 34 | stream=ProgressBarStream(self.stdout) 35 | ) 36 | for obj in chunked_iterator(qs, obj_anonymizer.chunk_size): 37 | obj_anonymizer().anonymize_obj(obj) 38 | bar.update() 39 | 40 | def _anonymize(self, obj_anonymizer, model): 41 | qs = model.objects.all() 42 | if obj_anonymizer.can_anonymize_qs: 43 | self._anonymize_by_qs(obj_anonymizer, qs) 44 | else: 45 | self._anonymize_by_obj(obj_anonymizer, qs) 46 | 47 | def _get_full_model_name(self, model): 48 | return '{}.{}'.format(model._meta.app_label, model._meta.model_name) 49 | 50 | def handle(self, models, *args, **options): 51 | models = {v.strip().lower() for v in models.split(',')} if models else None 52 | for obj_anonymizer in list(anonymizer_register()): 53 | model = obj_anonymizer.Meta.model 54 | if not models or self._get_full_model_name(model) in models: 55 | self._anonymize(obj_anonymizer, model) 56 | self.stdout.write('Data was anonymized') 57 | -------------------------------------------------------------------------------- /gdpr/management/commands/deactivate_expired_reasons.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.core.management.base import BaseCommand 4 | 5 | from gdpr.models import LegalReason 6 | 7 | from chamber.utils.tqdm import tqdm 8 | 9 | from django.conf import settings 10 | from django.db import transaction 11 | 12 | 13 | logger = logging.getLogger(getattr(settings, 'GDPR_DEACTIVATE_EXPIRED_REASONS_LOGGER', __name__)) 14 | 15 | 16 | class Command(BaseCommand): 17 | 18 | def _slice_queryset(self, queryset): 19 | chunk_size = getattr(settings, 'GDPR_DEACTIVATE_EXPIRED_REASONS_CHUNK_SIZE', None) 20 | return queryset[:chunk_size] if chunk_size else queryset 21 | 22 | def handle(self, *args, **options): 23 | self.stdout.write('Anonymize expired data of expired legal reasons') 24 | 25 | legal_reason_to_expire_qs = LegalReason.objects.filter_active_and_expired() 26 | total_number_of_objects = legal_reason_to_expire_qs.count() 27 | sliced_qs = self._slice_queryset(legal_reason_to_expire_qs) 28 | 29 | for legal_reason in tqdm(sliced_qs.iterator(), total=sliced_qs.count(), file=self.stdout): 30 | legal_reason.expire() 31 | 32 | remaining_number_of_objects = legal_reason_to_expire_qs.count() 33 | 34 | self.stdout.write( 35 | f' Total number of expired reasons: {total_number_of_objects}' 36 | ) 37 | self.stdout.write( 38 | f' Remaining number of expired reasons: {remaining_number_of_objects}' 39 | ) 40 | 41 | if remaining_number_of_objects == 0: 42 | logger.info('Command "deactivate_expired_reasons" finished') 43 | -------------------------------------------------------------------------------- /gdpr/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11 on 2018-04-16 10:07 3 | from __future__ import unicode_literals 4 | 5 | import django.db.models.deletion 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | initial = True 11 | 12 | dependencies = [ 13 | ('contenttypes', '0002_remove_content_type_name'), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='AnonymizedData', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='created at')), 22 | ('changed_at', models.DateTimeField(auto_now=True, db_index=True, verbose_name='changed at')), 23 | ('field', models.CharField(max_length=250, verbose_name='anonymized field name')), 24 | ('object_id', models.TextField(verbose_name='related object ID')), 25 | ('is_active', models.BooleanField(default=True, verbose_name='is active')), 26 | ('content_type', 27 | models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType', 28 | verbose_name='related object content type')), 29 | ], 30 | options={ 31 | 'verbose_name': 'anonymized data', 32 | 'verbose_name_plural': 'anonymized data', 33 | 'ordering': ('-created_at',), 34 | }, 35 | ), 36 | migrations.CreateModel( 37 | name='LegalReason', 38 | fields=[ 39 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 40 | ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='created at')), 41 | ('changed_at', models.DateTimeField(auto_now=True, db_index=True, verbose_name='changed at')), 42 | ('expires_at', models.DateTimeField(db_index=True, verbose_name='expires at')), 43 | ('tag', models.CharField(blank=True, max_length=100, null=True, verbose_name='tag')), 44 | ('is_active', models.BooleanField(default=True, verbose_name='is active')), 45 | ('purpose_slug', models.CharField(max_length=100, verbose_name='purpose')), 46 | ('source_object_id', models.TextField(verbose_name='source object ID')), 47 | ('source_object_content_type', 48 | models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType', 49 | verbose_name='source object content type')), 50 | ], 51 | options={ 52 | 'verbose_name': 'legal reason', 53 | 'verbose_name_plural': 'legal reasons', 54 | 'ordering': ('-created_at',), 55 | }, 56 | ), 57 | migrations.CreateModel( 58 | name='LegalReasonRelatedObject', 59 | fields=[ 60 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 61 | ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='created at')), 62 | ('changed_at', models.DateTimeField(auto_now=True, db_index=True, verbose_name='changed at')), 63 | ('object_id', models.TextField(verbose_name='related object ID')), 64 | ('content_type', 65 | models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType', 66 | verbose_name='related object content type')), 67 | ('legal_reason', 68 | models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='related_objects', 69 | to='gdpr.LegalReason', verbose_name='legal reason')), 70 | ], 71 | options={ 72 | 'verbose_name': 'legal reason related object', 73 | 'verbose_name_plural': 'legal reasons related objects', 74 | 'ordering': ('-created_at',), 75 | }, 76 | ), 77 | migrations.AddField( 78 | model_name='anonymizeddata', 79 | name='expired_reason', 80 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, 81 | to='gdpr.LegalReason', verbose_name='expired reason'), 82 | ), 83 | ] 84 | -------------------------------------------------------------------------------- /gdpr/migrations/0002_auto_20180509_1518.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11 on 2018-05-09 13:18 3 | from __future__ import unicode_literals 4 | 5 | import datetime 6 | 7 | from django.db import migrations, models 8 | from django.utils.timezone import utc 9 | 10 | 11 | class Migration(migrations.Migration): 12 | dependencies = [ 13 | ('gdpr', '0001_initial'), 14 | ] 15 | 16 | operations = [ 17 | migrations.RenameField( 18 | model_name='legalreasonrelatedobject', 19 | old_name='content_type', 20 | new_name='object_content_type', 21 | ), 22 | migrations.AddField( 23 | model_name='legalreason', 24 | name='issued_at', 25 | field=models.DateTimeField(default=datetime.datetime(2018, 5, 9, 13, 18, 7, 317147, tzinfo=utc), 26 | verbose_name='issued at'), 27 | preserve_default=False, 28 | ), 29 | migrations.AlterField( 30 | model_name='legalreason', 31 | name='purpose_slug', 32 | field=models.CharField(choices=[], max_length=100, verbose_name='purpose'), 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /gdpr/migrations/0003.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.16 on 2018-12-19 15:17 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | from django.db.models import Count 7 | from tqdm import tqdm 8 | 9 | 10 | def remove_duplicate_legal_reasons(apps, purpose_slug, source_object_content_type, source_object_id): 11 | LegalReason = apps.get_model('gdpr', 'LegalReason') 12 | duplicate_legal_reason_qs = LegalReason.objects.filter( 13 | purpose_slug=purpose_slug, 14 | source_object_content_type=source_object_content_type, 15 | source_object_id=source_object_id 16 | ) 17 | 18 | if duplicate_legal_reason_qs.filter(is_active=True).count() > 0: 19 | duplicate_legal_reason_qs.filter(is_active=False).delete() 20 | 21 | latest_legal_reason = duplicate_legal_reason_qs.latest('expires_at') 22 | duplicate_legal_reason_qs.exclude(pk=latest_legal_reason.pk).delete() 23 | 24 | 25 | def check_uniqueness_and_keep_latest_active_legal_reason(apps, schema_editor): 26 | LegalReason = apps.get_model('gdpr', 'LegalReason') 27 | check_qs = LegalReason.objects.values('purpose_slug', 'source_object_content_type', 'source_object_id').annotate( 28 | lr_count=Count('purpose_slug')).filter(lr_count__gt=1).order_by('-lr_count').distinct() 29 | 30 | for legal_reason in tqdm(check_qs.all()): 31 | remove_duplicate_legal_reasons( 32 | apps, legal_reason['purpose_slug'], legal_reason['source_object_content_type'], 33 | legal_reason['source_object_id'] 34 | ) 35 | 36 | 37 | def remove_duplicate_legal_reasons_relatives(apps, legal_reason, object_content_type, object_id): 38 | LegalReasonRelatedObject = apps.get_model('gdpr', 'LegalReasonRelatedObject') 39 | duplicates_qs = LegalReasonRelatedObject.objects.filter( 40 | legal_reason=legal_reason, 41 | object_content_type=object_content_type, 42 | object_id=object_id 43 | ) 44 | latest_legal_reason_related_object = duplicates_qs.latest('created_at') 45 | duplicates_qs.exclude(pk=latest_legal_reason_related_object.pk).delete() 46 | 47 | 48 | def check_uniqueness_and_keep_latest_active_legal_reason_related_object(apps, schema_editor): 49 | LegalReasonRelatedObject = apps.get_model('gdpr', 'LegalReasonRelatedObject') 50 | check_qs = LegalReasonRelatedObject.objects.values('legal_reason', 'object_content_type', 'object_id').annotate( 51 | lrro_count=Count('legal_reason')).filter(lrro_count__gt=1).order_by('-lrro_count').distinct() 52 | 53 | for legal_reason_related_object in tqdm(check_qs.all(), ncols=100): 54 | remove_duplicate_legal_reasons_relatives(apps, legal_reason_related_object['legal_reason'], 55 | legal_reason_related_object['object_content_type'], 56 | legal_reason_related_object['object_id'] 57 | ) 58 | 59 | 60 | class Migration(migrations.Migration): 61 | dependencies = [ 62 | ('gdpr', '0002_auto_20180509_1518'), 63 | ('contenttypes', '0002_remove_content_type_name'), 64 | ] 65 | operations = [ 66 | migrations.AlterField( 67 | model_name='legalreason', 68 | name='purpose_slug', 69 | field=models.CharField(choices=[], db_index=True, 70 | max_length=100, verbose_name='purpose'), 71 | ), 72 | migrations.AlterField( 73 | model_name='legalreason', 74 | name='source_object_id', 75 | field=models.TextField(verbose_name='source object ID', db_index=True), 76 | ), 77 | migrations.AlterField( 78 | model_name='legalreasonrelatedobject', 79 | name='object_id', 80 | field=models.TextField(verbose_name='related object ID', db_index=True), 81 | ), 82 | migrations.RunPython(check_uniqueness_and_keep_latest_active_legal_reason), 83 | migrations.RunPython(check_uniqueness_and_keep_latest_active_legal_reason_related_object), 84 | ] 85 | -------------------------------------------------------------------------------- /gdpr/migrations/0004.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.16 on 2018-12-19 15:17 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('gdpr', '0003'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterUniqueTogether( 16 | name='legalreason', 17 | unique_together=set([('purpose_slug', 'source_object_content_type', 'source_object_id')]), 18 | ), 19 | migrations.AlterUniqueTogether( 20 | name='legalreasonrelatedobject', 21 | unique_together=set([('legal_reason', 'object_content_type', 'object_id')]), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /gdpr/migrations/0005_anon_2_0.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.8 on 2019-02-25 16:27 3 | from __future__ import unicode_literals 4 | 5 | import django.db.models.deletion 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ('contenttypes', '0002_remove_content_type_name'), 12 | ('gdpr', '0004'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name='anonymizeddata', 18 | name='content_type', 19 | field=models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to='contenttypes.ContentType', 20 | verbose_name='related object content type'), 21 | ), 22 | migrations.AlterField( 23 | model_name='anonymizeddata', 24 | name='expired_reason', 25 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, 26 | to='gdpr.LegalReason', verbose_name='expired reason'), 27 | ), 28 | migrations.AlterField( 29 | model_name='legalreason', 30 | name='expires_at', 31 | field=models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='expires at'), 32 | ), 33 | migrations.AlterField( 34 | model_name='legalreason', 35 | name='source_object_content_type', 36 | field=models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to='contenttypes.ContentType', 37 | verbose_name='source object content type'), 38 | ), 39 | migrations.AlterField( 40 | model_name='legalreason', 41 | name='source_object_id', 42 | field=models.TextField(verbose_name='source object ID'), 43 | ), 44 | migrations.AlterField( 45 | model_name='legalreasonrelatedobject', 46 | name='object_content_type', 47 | field=models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to='contenttypes.ContentType', 48 | verbose_name='related object content type'), 49 | ), 50 | migrations.AlterField( 51 | model_name='legalreasonrelatedobject', 52 | name='object_id', 53 | field=models.TextField(verbose_name='related object ID'), 54 | ), 55 | migrations.AlterUniqueTogether( 56 | name='anonymizeddata', 57 | unique_together=set([('content_type', 'object_id', 'field')]), 58 | ), 59 | migrations.AlterUniqueTogether( 60 | name='legalreason', 61 | unique_together=set([('purpose_slug', 'source_object_content_type', 'source_object_id')]), 62 | ), 63 | migrations.AlterUniqueTogether( 64 | name='legalreasonrelatedobject', 65 | unique_together=set([('legal_reason', 'object_content_type', 'object_id')]), 66 | ), 67 | ] 68 | -------------------------------------------------------------------------------- /gdpr/migrations/0006_auto_20190228_0725.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.8 on 2019-02-28 13:25 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('gdpr', '0005_anon_2_0'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='legalreason', 17 | name='source_object_id', 18 | field=models.TextField(db_index=True, verbose_name='source object ID'), 19 | ), 20 | migrations.AlterField( 21 | model_name='legalreasonrelatedobject', 22 | name='object_id', 23 | field=models.TextField(db_index=True, verbose_name='related object ID'), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /gdpr/migrations/0007_migration.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.26 on 2020-02-17 13:08 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | import enumfields.fields 7 | import gdpr.enums 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ('gdpr', '0006_auto_20190228_0725'), 14 | ] 15 | 16 | operations = [ 17 | migrations.AddField( 18 | model_name='legalreason', 19 | name='state', 20 | field=enumfields.fields.IntegerEnumField(default=1, enum=gdpr.enums.LegalReasonState, verbose_name='state'), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /gdpr/migrations/0008_migration.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.26 on 2020-02-17 13:08 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | import enumfields.fields 7 | import gdpr.enums 8 | 9 | 10 | def set_legal_reason_state(apps, schema_editor): 11 | LegalReason = apps.get_model('gdpr', 'LegalReason') 12 | LegalReason.objects.filter(is_active=True).update(state=gdpr.enums.LegalReasonState.ACTIVE) 13 | LegalReason.objects.filter(is_active=False).update(state=gdpr.enums.LegalReasonState.EXPIRED) 14 | 15 | 16 | class Migration(migrations.Migration): 17 | 18 | dependencies = [ 19 | ('gdpr', '0007_migration'), 20 | ] 21 | 22 | operations = [ 23 | migrations.RunPython(set_legal_reason_state), 24 | ] 25 | -------------------------------------------------------------------------------- /gdpr/migrations/0009_migration.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.26 on 2020-02-17 13:26 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('gdpr', '0008_migration'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RemoveField( 16 | model_name='legalreason', 17 | name='is_active', 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /gdpr/migrations/0010_migration.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1 on 2021-01-02 10:52 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('gdpr', '0009_migration'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='legalreason', 15 | name='purpose_slug', 16 | field=models.CharField(db_index=True, max_length=100, verbose_name='purpose'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /gdpr/migrations/0011_migration.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1 on 2021-04-18 10:56 2 | 3 | from django.db import migrations 4 | import enumfields.fields 5 | import gdpr.enums 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('gdpr', '0010_migration'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='legalreason', 17 | name='state', 18 | field=enumfields.fields.IntegerEnumField( 19 | db_index=True, default=1, enum=gdpr.enums.LegalReasonState, verbose_name='state' 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /gdpr/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/druids/django-GDPR/1595a652651100d5a1809c83a1516e5657cf2a6b/gdpr/migrations/__init__.py -------------------------------------------------------------------------------- /gdpr/mixins.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from django.contrib.contenttypes.models import ContentType 4 | from django.core.exceptions import ImproperlyConfigured 5 | from django.db.models import Model, QuerySet 6 | from django.db.utils import Error 7 | 8 | from gdpr.models import AnonymizedData, LegalReason, LegalReasonRelatedObject 9 | 10 | 11 | class AnonymizationModelMixin: 12 | 13 | @property 14 | def content_type(self) -> ContentType: 15 | """Get model ContentType""" 16 | return ContentType.objects.get_for_model(self.__class__) 17 | 18 | def _anonymize_obj(self, *args, **kwargs): 19 | from gdpr.loading import anonymizer_register 20 | if self.__class__ in anonymizer_register: 21 | anonymizer_register[self.__class__]().anonymize_obj(self, *args, **kwargs) 22 | else: 23 | raise ImproperlyConfigured('%s does not have registered anonymizer.' % self.__class__) 24 | 25 | def _deanonymize_obj(self, *args, **kwargs): 26 | from gdpr.loading import anonymizer_register 27 | if self.__class__ in anonymizer_register: 28 | anonymizer_register[self.__class__]().deanonymize_obj(self, *args, **kwargs) 29 | else: 30 | raise ImproperlyConfigured('%s does not have registered anonymizer.' % self.__class__) 31 | 32 | def get_consents(self) -> QuerySet: 33 | return LegalReason.objects.filter_source_instance(self) 34 | 35 | def create_consent(self, purpose_slug: str, *args, **kwargs) -> LegalReason: 36 | return LegalReason.objects.create_consent(purpose_slug, self, *args, **kwargs) 37 | 38 | def deactivate_consent(self, purpose_slug: str): 39 | LegalReason.objects.deactivate_consent(purpose_slug, self) 40 | 41 | def delete(self, *args, **kwargs): 42 | """Cleanup anonymization metadata""" 43 | obj_id = str(self.pk) 44 | super().delete(*args, **kwargs) 45 | try: 46 | AnonymizedData.objects.filter(object_id=obj_id, content_type=self.content_type).delete() 47 | except Error as e: 48 | # Better to just have some leftovers then to fail 49 | warnings.warn(f'An exception {str(e)} occurred during cleanup of {str(self)}') 50 | try: 51 | LegalReasonRelatedObject.objects.filter(object_id=obj_id, object_content_type=self.content_type).delete() 52 | except Error as e: 53 | # Better to just have some leftovers then to fail 54 | warnings.warn(f'An exception {str(e)} occurred during cleanup of {str(self)}') 55 | 56 | 57 | class AnonymizationModel(AnonymizationModelMixin, Model): 58 | class Meta: 59 | abstract = True 60 | -------------------------------------------------------------------------------- /gdpr/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from dateutil.relativedelta import relativedelta 3 | 4 | from django.contrib.contenttypes.fields import GenericForeignKey 5 | from django.contrib.contenttypes.models import ContentType 6 | from django.db import models, transaction 7 | from django.db.models import Q 8 | from django.utils import timezone 9 | from django.utils.translation import gettext_lazy as _ 10 | 11 | from chamber.models import SmartModel 12 | 13 | from typing import TYPE_CHECKING, Iterable, Optional, Type 14 | 15 | from enumfields import IntegerEnumField 16 | 17 | from .enums import LegalReasonState 18 | from .loading import purpose_register 19 | 20 | 21 | if TYPE_CHECKING: 22 | from gdpr.purposes.default import AbstractPurpose 23 | 24 | 25 | class LegalReasonManager(models.Manager): 26 | 27 | def create_consent(self, purpose_slug: str, source_object, issued_at: Optional[datetime] = None, 28 | tag: Optional[str] = None, related_objects: Optional[Iterable[Type[models.Model]]] = None, 29 | expires_at: Optional[datetime] = None): 30 | """ 31 | Create (or update, if it exist) a LegalReason with purpose slug for concrete object instance 32 | 33 | Args: 34 | purpose_slug: String of Legal Reason purpose 35 | source_object: Source object this Legal Reason is related to 36 | issued_at: When the Legal Reason consent was given 37 | tag: String that the developer can add to the created consent and use it to mark his business processes 38 | related_objects: Objects this Legal Reason relates to (ie. order, registrations etc.) 39 | expires_at: When the Legal Reason consent expires 40 | 41 | Returns: 42 | Legal Reason: LegalReason object 43 | """ 44 | try: 45 | purpose = purpose_register[purpose_slug] 46 | except KeyError: 47 | raise KeyError('Purpose with slug {} does not exits'.format(purpose_slug)) 48 | 49 | if purpose.source_model_class is not None and not source_object.__class__ == purpose.source_model_class: 50 | raise KeyError( 51 | 'Purpose with slug {} cannot be created for model {}'.format(purpose_slug, source_object.__class__) 52 | ) 53 | 54 | issued_at = issued_at or timezone.now() 55 | 56 | legal_reason, created = LegalReason.objects.get_or_create( 57 | source_object_content_type=ContentType.objects.get_for_model(source_object.__class__), 58 | source_object_id=str(source_object.pk), 59 | purpose_slug=purpose_slug, 60 | defaults={ 61 | 'issued_at': issued_at, 62 | 'expires_at': expires_at if expires_at else issued_at + purpose.expiration_timedelta, 63 | 'tag': tag, 64 | 'state': LegalReasonState.ACTIVE, 65 | } 66 | ) 67 | 68 | if not created: 69 | legal_reason.change_and_save( 70 | expires_at=expires_at if expires_at else timezone.now() + purpose.expiration_timedelta, 71 | tag=tag, 72 | state=LegalReasonState.ACTIVE 73 | ) 74 | 75 | for related_object in related_objects or (): 76 | legal_reason.related_objects.update_or_create( 77 | object_content_type=ContentType.objects.get_for_model(related_object.__class__), 78 | object_id=related_object.pk 79 | ) 80 | 81 | return legal_reason 82 | 83 | def deactivate_consent(self, purpose_slug: str, source_object): 84 | """ 85 | Deactivate/Remove consent (Legal reason) for source_object, purpose_slug combination 86 | 87 | Args: 88 | purpose_slug: Purpose slug to deactivate consent for 89 | source_object: Source object to deactivate consent for 90 | 91 | Returns: 92 | List of LegalReason objects 93 | """ 94 | reasons = [] 95 | for reason in LegalReason.objects.filter_source_instance_active_non_expired_purpose(source_object, 96 | purpose_slug): 97 | reason.deactivate() 98 | reasons.append(reason) 99 | return reasons 100 | 101 | def exists_valid_consent(self, purpose_slug: str, source_object): 102 | """ 103 | Returns True if source_object has valid (ie. active and non-expired) consent (Legal Reason) 104 | 105 | Args: 106 | purpose_slug: Purpose_slug to check consent for 107 | source_object: Source object to check consent for 108 | """ 109 | return LegalReason.objects.filter_source_instance_active_non_expired_purpose( 110 | source_object, purpose_slug).exists() 111 | 112 | def exists_deactivated_consent(self, purpose_slug: str, source_object): 113 | """ 114 | Returns True if source_object has deactivated consent (Legal Reason) 115 | 116 | Args: 117 | purpose_slug: Purpose_slug to check consent for 118 | source_object: Source object to check consent for 119 | """ 120 | return self.filter_source_instance(source_object).filter( 121 | state=LegalReasonState.DEACTIVATED, 122 | purpose_slug=purpose_slug 123 | ).exists() 124 | 125 | def expire_old_consents(self): 126 | """ 127 | Anonymize and expire consents which have past their `expires_at`. 128 | """ 129 | for reason in LegalReason.objects.filter_active_and_expired(): 130 | reason.expire() 131 | 132 | 133 | class LegalReasonQuerySet(models.QuerySet): 134 | 135 | def filter_expired_retaining_data_in_last_days(self, days=None): 136 | """ 137 | Filters all Legal Reason that retain data and that expired in last days 138 | 139 | Args: 140 | days: Number of days in the past. If not provided, all Legal Reasons retaining data which expired in the 141 | past will be returned. 142 | """ 143 | purpose_slugs_retaining_data = [slug for slug, cls in purpose_register.items() if cls.fields] 144 | 145 | filter_keys = { 146 | 'expires_at__lt': timezone.now(), 147 | } if days is None else { 148 | 'expires_at__gt': timezone.now() - relativedelta(days=days), 149 | 'expires_at__lt': timezone.now() 150 | } 151 | 152 | return self.filter(state=LegalReasonState.ACTIVE, purpose_slug__in=purpose_slugs_retaining_data, **filter_keys) 153 | 154 | def filter_non_expired(self): 155 | return self.filter(Q(expires_at__gte=timezone.now()) | Q(expires_at=None)) 156 | 157 | def filter_expired(self): 158 | return self.filter(expires_at__lte=timezone.now()) 159 | 160 | def filter_active(self): 161 | return self.filter(state=LegalReasonState.ACTIVE) 162 | 163 | def filter_active_and_non_expired(self): 164 | return self.filter_active().filter_non_expired() 165 | 166 | def filter_active_and_expired(self): 167 | return self.filter_active().filter_expired() 168 | 169 | def filter_source_instance(self, source_object): 170 | return self.filter( 171 | source_object_content_type=ContentType.objects.get_for_model(source_object.__class__), 172 | source_object_id=str(source_object.pk) 173 | ) 174 | 175 | def filter_source_instance_active_non_expired(self, source_object): 176 | return self.filter_source_instance(source_object).filter_active_and_non_expired() 177 | 178 | def filter_source_instance_active_non_expired_purpose(self, source_object, purpose_slug: str): 179 | return self.filter_source_instance_active_non_expired(source_object).filter( 180 | purpose_slug=purpose_slug 181 | ) 182 | 183 | 184 | class LegalReason(SmartModel): 185 | objects = LegalReasonManager.from_queryset(LegalReasonQuerySet)() 186 | 187 | issued_at = models.DateTimeField( 188 | verbose_name=_('issued at'), 189 | null=False, 190 | blank=False, 191 | ) 192 | expires_at = models.DateTimeField( 193 | verbose_name=_('expires at'), 194 | null=True, 195 | blank=True, 196 | db_index=True 197 | ) 198 | tag = models.CharField( 199 | verbose_name=_('tag'), 200 | null=True, 201 | blank=True, 202 | max_length=100 203 | ) 204 | state = IntegerEnumField( 205 | verbose_name=_('state'), 206 | null=False, 207 | blank=False, 208 | enum=LegalReasonState, 209 | default=LegalReasonState.ACTIVE, 210 | db_index=True 211 | ) 212 | purpose_slug = models.CharField( 213 | verbose_name=_('purpose'), 214 | null=False, 215 | blank=False, 216 | max_length=100, 217 | db_index=True 218 | ) 219 | source_object_content_type = models.ForeignKey( 220 | ContentType, 221 | verbose_name=_('source object content type'), 222 | null=False, 223 | blank=False, 224 | on_delete=models.DO_NOTHING 225 | ) 226 | source_object_id = models.TextField( 227 | verbose_name=_('source object ID'), 228 | null=False, blank=False, 229 | db_index=True 230 | ) 231 | source_object = GenericForeignKey( 232 | 'source_object_content_type', 'source_object_id' 233 | ) 234 | 235 | class Meta: 236 | verbose_name = _('legal reason') 237 | verbose_name_plural = _('legal reasons') 238 | ordering = ('-created_at',) 239 | unique_together = ('purpose_slug', 'source_object_content_type', 'source_object_id') 240 | 241 | def __str__(self): 242 | return f'{self.purpose.name}' 243 | 244 | @property 245 | def is_active(self): 246 | return self.state == LegalReasonState.ACTIVE 247 | 248 | @property 249 | def purpose(self) -> Type["AbstractPurpose"]: 250 | return purpose_register.get(self.purpose_slug, None) 251 | 252 | def _anonymize_obj(self, *args, **kwargs): 253 | purpose_register[self.purpose_slug]().anonymize_obj(self.source_object, self, *args, **kwargs) 254 | 255 | def _deanonymize_obj(self, *args, **kwargs): 256 | purpose_register[self.purpose_slug]().deanonymize_obj(self.source_object, *args, **kwargs) 257 | 258 | def expire(self): 259 | """Anonymize obj and set state as expired.""" 260 | with transaction.atomic(): 261 | self._anonymize_obj() 262 | self.change_and_save(state=LegalReasonState.EXPIRED) 263 | 264 | def deactivate(self): 265 | """Deactivate obj and run anonymization.""" 266 | with transaction.atomic(): 267 | self._anonymize_obj() 268 | self.change_and_save(state=LegalReasonState.DEACTIVATED) 269 | 270 | def renew(self): 271 | with transaction.atomic(): 272 | self.change_and_save( 273 | expires_at=timezone.now() + purpose_register[self.purpose_slug]().expiration_timedelta, 274 | state=LegalReasonState.ACTIVE 275 | ) 276 | self._deanonymize_obj() 277 | 278 | 279 | class LegalReasonRelatedObject(SmartModel): 280 | legal_reason = models.ForeignKey( 281 | LegalReason, 282 | verbose_name=_('legal reason'), 283 | null=False, 284 | blank=False, 285 | related_name='related_objects', 286 | on_delete=models.CASCADE 287 | ) 288 | object_content_type = models.ForeignKey( 289 | ContentType, 290 | verbose_name=_('related object content type'), 291 | null=False, 292 | blank=False, 293 | on_delete=models.DO_NOTHING 294 | ) 295 | object_id = models.TextField( 296 | verbose_name=_('related object ID'), 297 | null=False, 298 | blank=False, 299 | db_index=True 300 | ) 301 | object = GenericForeignKey( 302 | 'object_content_type', 'object_id' 303 | ) 304 | 305 | class Meta: 306 | verbose_name = _('legal reason related object') 307 | verbose_name_plural = _('legal reasons related objects') 308 | ordering = ('-created_at',) 309 | unique_together = ('legal_reason', 'object_content_type', 'object_id') 310 | 311 | def __str__(self): 312 | return '{legal_reason} {object}'.format(legal_reason=self.legal_reason, object=self.object) 313 | 314 | 315 | class AnonymizedDataQuerySet(models.QuerySet): 316 | 317 | def filter_source_instance_active(self, source_object): 318 | return self.filter( 319 | content_type=ContentType.objects.get_for_model(source_object.__class__), 320 | object_id=str(source_object.pk), 321 | is_active=True 322 | ) 323 | 324 | 325 | class AnonymizedData(SmartModel): 326 | objects = models.Manager.from_queryset(AnonymizedDataQuerySet)() 327 | 328 | field = models.CharField( 329 | verbose_name=_('anonymized field name'), 330 | max_length=250, 331 | null=False, 332 | blank=False 333 | ) 334 | content_type = models.ForeignKey( 335 | ContentType, 336 | verbose_name=_('related object content type'), 337 | null=False, 338 | blank=False, 339 | on_delete=models.DO_NOTHING 340 | ) 341 | object_id = models.TextField( 342 | verbose_name=_('related object ID'), 343 | null=False, 344 | blank=False 345 | ) 346 | object = GenericForeignKey( 347 | 'content_type', 'object_id' 348 | ) 349 | is_active = models.BooleanField( 350 | verbose_name=_('is active'), 351 | default=True 352 | ) 353 | expired_reason = models.ForeignKey( 354 | LegalReason, 355 | verbose_name=_('expired reason'), 356 | null=True, 357 | blank=True, 358 | on_delete=models.SET_NULL 359 | ) 360 | 361 | class Meta: 362 | verbose_name = _('anonymized data') 363 | verbose_name_plural = _('anonymized data') 364 | ordering = ('-created_at',) 365 | unique_together = ('content_type', 'object_id', 'field') 366 | 367 | def __str__(self): 368 | return '{field} {object}'.format(field=self.field, object=self.object) 369 | -------------------------------------------------------------------------------- /gdpr/purposes/__init__.py: -------------------------------------------------------------------------------- 1 | from .default import AbstractPurpose 2 | 3 | __all__ = ( 4 | 'AbstractPurpose' 5 | ) 6 | -------------------------------------------------------------------------------- /gdpr/purposes/default.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Any, Dict, KeysView, List, Optional, Tuple, Type, Union 2 | 3 | from django.core.exceptions import ImproperlyConfigured 4 | from django.db.models import Model, Q 5 | from django.apps import apps 6 | from django.db.models.utils import make_model_tuple 7 | 8 | from gdpr.enums import LegalReasonState 9 | from gdpr.fields import Fields 10 | from gdpr.loading import anonymizer_register, purpose_register 11 | 12 | if TYPE_CHECKING: 13 | from gdpr.models import LegalReason 14 | from gdpr.anonymizers import ModelAnonymizer 15 | 16 | FieldList = Union[List[str], Tuple, KeysView[str]] # List, tuple or return of dict keys() method. 17 | FieldMatrix = Union[str, Tuple[Any, ...]] 18 | RelatedMatrix = Dict[str, FieldMatrix] 19 | 20 | 21 | class PurposeMetaclass(type): 22 | 23 | def __new__(mcs, name, bases, attrs): 24 | from gdpr.loading import purpose_register 25 | 26 | new_class = super().__new__(mcs, name, bases, attrs) 27 | if hasattr(new_class, 'slug') and new_class.slug: 28 | if new_class.slug in purpose_register: 29 | raise ImproperlyConfigured('More anonymization purposes with slug {}'.format(new_class.slug)) 30 | 31 | purpose_register.register(new_class.slug, new_class) 32 | 33 | def set_source_model_class(model): 34 | new_class.source_model_class = model 35 | 36 | if isinstance(new_class.source_model_class, str): 37 | apps.lazy_model_operation(set_source_model_class, make_model_tuple(new_class.source_model_class)) 38 | 39 | return new_class 40 | 41 | def __str__(self): 42 | return str(self.name) 43 | 44 | 45 | class AbstractPurpose(metaclass=PurposeMetaclass): 46 | """ 47 | 48 | :param anonymize_legal_reason_related_object_only: If True anonymize only related objects which have links which 49 | have LegalReasonRelatedObject records. 50 | """ 51 | 52 | name: str 53 | slug: str 54 | fields: Union[str, Tuple[Any, ...], None] = None 55 | expiration_timedelta: Any 56 | anonymize_legal_reason_related_objects_only: bool = False # @TODO: Add support 57 | source_model_class: Optional[Union[str, Type[Model]]] = None 58 | 59 | def can_anonymize_obj(self, obj: Model, fields: FieldMatrix): 60 | return len(fields) != 0 61 | 62 | def get_parsed_fields(self, model: Type[Model]) -> Fields: 63 | return Fields(self.fields or (), model) 64 | 65 | def deanonymize_obj(self, obj: Model, fields: Optional[FieldMatrix] = None): 66 | fields = fields or self.fields or () 67 | if len(fields) == 0: 68 | # If there are no fields to deanonymize do nothing. 69 | return 70 | obj_model = obj.__class__ 71 | anonymizer: "ModelAnonymizer" = anonymizer_register[obj_model]() 72 | anonymizer.deanonymize_obj(obj, fields) 73 | 74 | def anonymize_obj(self, obj: Model, legal_reason: Optional["LegalReason"] = None, 75 | fields: Optional[FieldMatrix] = None): 76 | fields = fields or self.fields or () 77 | if not self.can_anonymize_obj(obj, fields): 78 | # If there are no fields to anonymize do nothing. 79 | return 80 | from gdpr.models import LegalReason # noqa 81 | 82 | obj_model = obj.__class__ 83 | anonymizer: "ModelAnonymizer" = anonymizer_register[obj_model]() 84 | 85 | # MultiLegalReason 86 | other_legal_reasons = LegalReason.objects.filter_source_instance(obj).filter(state=LegalReasonState.ACTIVE) 87 | if legal_reason: 88 | other_legal_reasons = other_legal_reasons.filter(~Q(pk=legal_reason.pk)) 89 | if other_legal_reasons.count() == 0: 90 | anonymizer.anonymize_obj(obj, legal_reason, self, fields) 91 | return 92 | 93 | from gdpr.loading import purpose_register 94 | 95 | parsed_fields = self.get_parsed_fields(obj_model) 96 | 97 | # Transform legal_reasons to fields 98 | for allowed_fields in [purpose_register[slug]().get_parsed_fields(obj_model) for slug in 99 | set([i.purpose_slug for i in other_legal_reasons])]: 100 | parsed_fields -= allowed_fields 101 | if len(parsed_fields) == 0: 102 | # If there are no fields to anonymize do nothing. 103 | return 104 | 105 | anonymizer.anonymize_obj(obj, legal_reason, self, parsed_fields) 106 | 107 | 108 | purposes_map = purpose_register # Backwards compatibility 109 | -------------------------------------------------------------------------------- /gdpr/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/druids/django-GDPR/1595a652651100d5a1809c83a1516e5657cf2a6b/gdpr/tests/__init__.py -------------------------------------------------------------------------------- /gdpr/tests/test_cs_fields.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | from django.test import TestCase 3 | 4 | from gdpr.anonymizers.local.cs import ( 5 | CzechAccountNumber, CzechAccountNumberFieldAnonymizer, CzechIBAN, CzechIBANSmartFieldAnonymizer, 6 | CzechIDCardFieldAnonymizer, CzechPersonalIDSmartFieldAnonymizer, CzechPhoneNumberFieldAnonymizer 7 | ) 8 | from germanium.tools import assert_equal, assert_false, assert_not_equal, assert_raises, assert_true 9 | 10 | 11 | class TestCzechAccountNumberField(TestCase): 12 | @classmethod 13 | def setUpTestData(cls): 14 | cls.field = CzechAccountNumberFieldAnonymizer() 15 | cls.encryption_key = 'LoremIpsumDolorSitAmet' 16 | 17 | def test_account_number_simple_field(self): 18 | account_number = '2501277007/2010' 19 | out = self.field.get_encrypted_value(account_number, self.encryption_key) 20 | 21 | assert_not_equal(out, account_number) 22 | 23 | out_decrypt = self.field.get_decrypted_value(out, self.encryption_key) 24 | 25 | assert_equal(out_decrypt, account_number) 26 | 27 | def test_account_number_simple_field_smart_method(self): 28 | field = CzechAccountNumberFieldAnonymizer(use_smart_method=True) 29 | account_number = '2501277007/2010' 30 | out = field.get_encrypted_value(account_number, self.encryption_key) 31 | 32 | assert_not_equal(out, account_number) 33 | 34 | out_decrypt = field.get_decrypted_value(out, self.encryption_key) 35 | 36 | assert_equal(out_decrypt, account_number) 37 | 38 | def test_account_number_with_pre_num_field(self): 39 | account_number = '19-2000145399/0800' 40 | out = self.field.get_encrypted_value(account_number, self.encryption_key) 41 | 42 | assert_not_equal(out, account_number) 43 | 44 | out_decrypt = self.field.get_decrypted_value(out, self.encryption_key) 45 | 46 | assert_equal(out_decrypt, account_number) 47 | 48 | def test_account_number_with_pre_num_field_smart_method(self): 49 | field = CzechAccountNumberFieldAnonymizer(use_smart_method=True) 50 | account_number = '19-2000145399/0800' 51 | out = field.get_encrypted_value(account_number, self.encryption_key) 52 | 53 | assert_not_equal(out, account_number) 54 | 55 | out_decrypt = field.get_decrypted_value(out, self.encryption_key) 56 | 57 | assert_equal(out_decrypt, account_number) 58 | 59 | def test_account_format_check(self): 60 | assert_true(CzechAccountNumber.parse('19-2000145399/0800').check_account_format()) 61 | assert_true(CzechAccountNumber.parse('2501277007/2010').check_account_format()) 62 | 63 | def test_brute_force(self): 64 | account = CzechAccountNumber.parse('19-2000145399/0800') 65 | key = 314 66 | original_account_num = account.num 67 | 68 | account.brute_force_next(key) 69 | 70 | assert_not_equal(original_account_num, account.num) 71 | 72 | account.brute_force_prev(key) 73 | 74 | assert_equal(original_account_num, account.num) 75 | 76 | 77 | class TestCzechIBANSmartFieldAnonymizer(TestCase): 78 | @classmethod 79 | def setUpTestData(cls): 80 | cls.field = CzechIBANSmartFieldAnonymizer() 81 | cls.encryption_key = 'LoremIpsumDolorSitAmet' 82 | cls.text_iban = 'CZ65 0800 0000 1920 0014 5399' 83 | cls.no_space_text_iban = 'CZ6508000000192000145399' 84 | cls.invalid_text_iban = 'CZ00 0800 0000 1920 0014 5399' 85 | cls.no_pre_num_iban = 'CZ4601000000000099550247' 86 | 87 | def test_czech_iban_field(self): 88 | out = self.field.get_encrypted_value(self.text_iban, self.encryption_key) 89 | assert_not_equal(out, self.text_iban) 90 | 91 | out_decrypted = self.field.get_decrypted_value(out, self.encryption_key) 92 | assert_equal(out_decrypted, self.text_iban) 93 | 94 | def test_czech_iban_field_no_space(self): 95 | out = self.field.get_encrypted_value(self.no_space_text_iban, self.encryption_key) 96 | assert_not_equal(out, self.no_space_text_iban) 97 | assert_not_equal(out, self.text_iban) 98 | 99 | out_decrypted = self.field.get_decrypted_value(out, self.encryption_key) 100 | assert_equal(out_decrypted, self.no_space_text_iban) 101 | 102 | def test_czech_iban_field_get_encrypted_value_invalid_format_raises(self): 103 | assert_raises(ValidationError, self.field.get_encrypted_value, self.invalid_text_iban, self.encryption_key) 104 | 105 | def test_czech_iban_field_get_decrypted_value_invalid_format_raises(self): 106 | assert_raises(ValidationError, self.field.get_decrypted_value, self.invalid_text_iban, self.encryption_key) 107 | 108 | def test_czech_iban_parse_and_str_with_spaces(self): 109 | assert_equal(self.text_iban, str(CzechIBAN.parse(self.text_iban))) 110 | 111 | def test_czech_iban_parse_and_str_without_spaces(self): 112 | assert_equal(self.no_space_text_iban, str(CzechIBAN.parse(self.no_space_text_iban))) 113 | 114 | def test_czech_iban_check_format(self): 115 | assert_true(CzechIBAN.parse(self.text_iban).check_iban_format()) 116 | 117 | def test_czech_iban_check_format_invalid(self): 118 | assert_false(CzechIBAN.parse(self.invalid_text_iban).check_iban_format()) 119 | 120 | def test_czech_iban_check_format_no_pre_num(self): 121 | assert_true(CzechIBAN.parse(self.no_pre_num_iban).check_iban_format()) 122 | 123 | def test_brute_force(self): 124 | account = CzechIBAN.parse(self.text_iban) 125 | key = 314 126 | 127 | account.brute_force_next(key) 128 | assert_not_equal(self.text_iban, str(account)) 129 | 130 | account.brute_force_prev(key) 131 | assert_equal(self.text_iban, str(account)) 132 | 133 | 134 | class TestCzechPhoneNumberField(TestCase): 135 | @classmethod 136 | def setUpTestData(cls): 137 | cls.field = CzechPhoneNumberFieldAnonymizer() 138 | cls.encryption_key = 'LoremIpsumDolorSitAmet' 139 | 140 | def test_basic_phone_number(self): 141 | phone_number = '608104120' 142 | out = self.field.get_encrypted_value(phone_number, self.encryption_key) 143 | 144 | assert_not_equal(phone_number, out) 145 | 146 | out_decrypt = self.field.get_decrypted_value(out, self.encryption_key) 147 | 148 | assert_equal(phone_number, out_decrypt) 149 | 150 | def test_plus_area_code_phone_number(self): 151 | phone_number = '+420608104120' 152 | out = self.field.get_encrypted_value(phone_number, self.encryption_key) 153 | 154 | assert_not_equal(phone_number, out) 155 | 156 | out_decrypt = self.field.get_decrypted_value(out, self.encryption_key) 157 | 158 | assert_equal(phone_number, out_decrypt) 159 | 160 | def test_zero_zero_area_code_phone_number(self): 161 | phone_number = '+420608104120' 162 | out = self.field.get_encrypted_value(phone_number, self.encryption_key) 163 | 164 | assert_not_equal(phone_number, out) 165 | 166 | out_decrypt = self.field.get_decrypted_value(out, self.encryption_key) 167 | 168 | assert_equal(phone_number, out_decrypt) 169 | 170 | 171 | class TestCzechIDCardFieldAnonymizer(TestCase): 172 | @classmethod 173 | def setUpTestData(cls): 174 | cls.field = CzechIDCardFieldAnonymizer() 175 | cls.encryption_key = 'LoremIpsumDolorSitAmet' 176 | 177 | def test_czech_id_card_field_anonymizer(self): 178 | id_card = "297065518" 179 | 180 | out = self.field.get_encrypted_value(id_card, self.encryption_key) 181 | assert_not_equal(id_card, out) 182 | 183 | out_decrypt = self.field.get_decrypted_value(out, self.encryption_key) 184 | assert_equal(id_card, out_decrypt) 185 | 186 | 187 | class TestCzechPersonalIDSmartFieldAnonymizer(TestCase): 188 | @classmethod 189 | def setUpTestData(cls): 190 | cls.field = CzechPersonalIDSmartFieldAnonymizer() 191 | cls.encryption_key = 'LoremIpsumDolorSitAmet' 192 | 193 | def test_czech_personal_id_smart_field_anonymizer(self): 194 | personal_id = "740104/0020" 195 | 196 | out = self.field.get_encrypted_value(personal_id, self.encryption_key) 197 | assert_not_equal(personal_id, out) 198 | 199 | out_decrypt = self.field.get_decrypted_value(out, self.encryption_key) 200 | assert_equal(personal_id, out_decrypt) 201 | 202 | def test_czech_personal_id_smart_field_anonymizer_no_slash(self): 203 | personal_id = "7401040020" 204 | 205 | out = self.field.get_encrypted_value(personal_id, self.encryption_key) 206 | assert_not_equal(personal_id, out) 207 | 208 | out_decrypt = self.field.get_decrypted_value(out, self.encryption_key) 209 | assert_equal(personal_id, out_decrypt) 210 | 211 | def test_czech_personal_id_smart_field_anonymizer_1954_change(self): 212 | personal_id = "540101/0021" 213 | 214 | out = self.field.get_encrypted_value(personal_id, self.encryption_key) 215 | assert_not_equal(personal_id, out) 216 | 217 | out_decrypt = self.field.get_decrypted_value(out, self.encryption_key) 218 | assert_equal(personal_id, out_decrypt) 219 | -------------------------------------------------------------------------------- /gdpr/tests/test_encryption.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | 3 | from django.test import TestCase 4 | 5 | from faker import Faker 6 | from gdpr.encryption import ( 7 | decrypt_email_address, decrypt_text, encrypt_email_address, encrypt_text, translate_iban, translate_number 8 | ) 9 | from germanium.tools import assert_equal, assert_not_equal 10 | 11 | 12 | IBANS = [ 13 | 'AL47 2121 1009 0000 0002 3569 8741', 14 | 'AD12 0001 2030 2003 5910 0100', 15 | 'AZ21 NABZ 0000 0000 1370 1000 1944', 16 | 'BH67 BMAG 0000 1299 1234 56', 17 | 'BE68 5390 0754 7034', 18 | 'BY13 NBRB 3600 9000 0000 2Z00 AB00', 19 | 'BA39 1290 0794 0102 8494', 20 | 'BR18 0036 0305 0000 1000 9795 493C 1', 21 | 'VG96 VPVG 0000 0123 4567 8901', 22 | 'BG80 BNBG 9661 1020 3456 78', 23 | 'ME25 5050 0001 2345 6789 51', 24 | 'CZ65 0800 0000 1920 0014 5399', 25 | 'DK50 0040 0440 1162 43', 26 | 'DO28 BAGR 0000 0001 2124 5361 1324', 27 | 'EE38 2200 2210 2014 5685', 28 | 'FI21 1234 5600 0007 85', 29 | 'FR14 2004 1010 0505 0001 3M02 606', 30 | 'GI75 NWBK 0000 0000 7099 453', 31 | 'GE29 NB00 0000 0101 9049 17', 32 | 'GT82 TRAJ 0102 0000 0012 1002 9690', 33 | 'HR12 1001 0051 8630 0016 0', 34 | 'IQ98 NBIQ 8501 2345 6789 012', 35 | 'IE29 AIBK 9311 5212 3456 78', 36 | 'IS14 0159 2600 7654 5510 7303 39', 37 | 'IT60 X054 2811 1010 0000 0123 456', 38 | 'IL62 0108 0000 0009 9999 999', 39 | 'JO94 CBJO 0010 0000 0000 0131 0003 02', 40 | 'QA58 DOHB 0000 1234 5678 90AB CDEF G', 41 | 'KZ86 125K ZT50 0410 0100', 42 | 'XK05 1212 0123 4567 8906', 43 | 'CR05 0152 0200 1026 2840 66', 44 | 'KW81 CBKU 0000 0000 0000 1234 5601 01', 45 | 'CY17 0020 0128 0000 0012 0052 7600', 46 | 'LB62 0999 0000 0001 0019 0122 9114', 47 | 'LI21 0881 0000 2324 013A A', 48 | 'LT12 1000 0111 0100 1000', 49 | 'LV80 BANK 0000 4351 9500 1', 50 | 'LU28 0019 4006 4475 0000', 51 | 'HU42 1177 3016 1111 1018 0000 0000', 52 | 'MK07 2501 2000 0058 984', 53 | 'MT84 MALT 0110 0001 2345 MTLC AST0 01S', 54 | 'MU17 BOMM 0101 1010 3030 0200 000M UR', 55 | 'MR13 0002 0001 0100 0012 3456 753', 56 | 'MD24 AG00 0225 1000 1310 4168', 57 | 'MC58 1122 2000 0101 2345 6789 030', 58 | 'DE89 3704 0044 0532 0130 00', 59 | 'NL91 ABNA 0417 1643 00', 60 | 'NO93 8601 1117 947', 61 | 'PK36 SCBL 0000 0011 2345 6702', 62 | 'PS92 PALS 0000 0000 0400 1234 5670 2', 63 | 'PL61 1090 1014 0000 0712 1981 2874', 64 | 'PT50 0002 0123 1234 5678 9015 4', 65 | 'AT61 1904 3002 3457 3201', 66 | 'RO49 AAAA 1B31 0075 9384 0000', 67 | 'GR16 0110 1250 0000 0001 2300 695', 68 | 'SV62 CENR 0000 0000 0000 0070 0025', 69 | 'SM86 U032 2509 8000 0000 0270 100', 70 | 'SA03 8000 0000 6080 1016 7519', 71 | 'SC18 SSCB 1101 0000 0000 0000 1497 USD', 72 | 'SK31 1200 0000 1987 4263 7541', 73 | 'SI56 2633 0001 2039 086', 74 | 'AE07 0331 2345 6789 0123 456', 75 | 'RS35 2600 0560 1001 6113 79', 76 | 'LC55 HEMM 0001 0001 0012 0012 0002 3015', 77 | 'ST68 0001 0001 0051 8453 1011 2', 78 | 'ES91 2100 0418 4502 0005 1332', 79 | 'SE45 5000 0000 0583 9825 7466', 80 | 'CH93 0076 2011 6238 5295 7', 81 | 'TN59 1000 6035 1835 9847 8831', 82 | 'TR33 0006 1005 1978 6457 8413 26', 83 | 'UA21 3223 1300 0002 6007 2335 6600 1', 84 | 'VA59 0011 2300 0012 3456 78', 85 | 'GB29 NWBK 6016 1331 9268 19', 86 | 'TL38 0080 0123 4567 8910 157', 87 | ] 88 | 89 | 90 | class TestEncryption(TestCase): 91 | """ 92 | Tests the `gdpr.encryption` module. 93 | """ 94 | 95 | def setUp(self): 96 | self.faker = Faker() 97 | self.encryption_key = 'LoremIpsumDolorSitAmet' 98 | self.numeric_encryption_key = '314159265358' 99 | 100 | def test_encrypt_text_full_name(self): 101 | """ 102 | Test function `gdpr.encryption.encrypt_text` by using human full name from Faker lib. 103 | """ 104 | cleartext = self.faker.name() 105 | 106 | ciphertext = encrypt_text(self.encryption_key, cleartext) 107 | assert_not_equal(cleartext, ciphertext, "The encrypted name is equal to the original name.") 108 | 109 | decrypted = decrypt_text(self.encryption_key, ciphertext) 110 | assert_equal(cleartext, decrypted, "The decrypted name is not equal to the original name.") 111 | 112 | def test_encrypt_email_address(self): 113 | """ 114 | Test function `gdpr.encryption.encrypt_email_address` by using email address from Faker lib. 115 | """ 116 | cleartext = self.faker.email() 117 | 118 | ciphertext = encrypt_email_address(self.encryption_key, cleartext) 119 | assert_not_equal(cleartext, ciphertext, "The encrypted email address is equal to the original email address.") 120 | 121 | decrypted = decrypt_email_address(self.encryption_key, ciphertext) 122 | assert_equal(cleartext, decrypted, "The decrypted email address is not equal to the original email address.") 123 | 124 | def test_encrypt_email_address_with_single_level_domain(self): 125 | """ 126 | Test function `gdpr.encryption.encrypt_email_address` by using email address from Faker lib. 127 | """ 128 | cleartext = f'{self.faker.email().split("@")[0]}@localhost' 129 | 130 | ciphertext = encrypt_email_address(self.encryption_key, cleartext) 131 | assert_not_equal(cleartext, ciphertext, "The encrypted email address is equal to the original email address.") 132 | assert_equal(ciphertext.split('@')[1], 'localhost') 133 | 134 | decrypted = decrypt_email_address(self.encryption_key, ciphertext) 135 | assert_equal(cleartext, decrypted, "The decrypted email address is not equal to the original email address.") 136 | 137 | def test_translate_iban(self): 138 | """ 139 | Test function `gdpr.encryption.translate_iban` by using an example IBAN for every country using IBAN system. 140 | """ 141 | for IBAN in IBANS: 142 | encrypted = translate_iban(self.encryption_key, IBAN) 143 | assert_not_equal(encrypted, IBAN, "The encrypted IBAN is equal to the original IBAN.") 144 | assert_equal(translate_iban(self.encryption_key, encrypted, False), IBAN, 145 | "The decrypted IBAN is not equal to the original IBAN.") 146 | 147 | def test_translate_number_whole_positive(self): 148 | """ 149 | Test metod `translate_number` on whole positive number. 150 | """ 151 | number = 42 152 | encrypted = translate_number(self.numeric_encryption_key, number) 153 | 154 | assert_not_equal(number, encrypted) 155 | assert_equal(type(number), type(encrypted)) 156 | 157 | decrypted = translate_number(self.numeric_encryption_key, encrypted, encrypt=False) 158 | 159 | assert_equal(number, decrypted) 160 | assert_equal(type(number), type(decrypted)) 161 | 162 | def test_translate_number_whole_negative(self): 163 | """ 164 | Test metod `translate_number` on whole negative number. 165 | """ 166 | number = -42 167 | encrypted = translate_number(self.numeric_encryption_key, number) 168 | 169 | assert_not_equal(number, encrypted) 170 | assert_equal(type(number), type(encrypted)) 171 | 172 | decrypted = translate_number(self.numeric_encryption_key, encrypted, encrypt=False) 173 | 174 | assert_equal(number, decrypted) 175 | assert_equal(type(number), type(decrypted)) 176 | 177 | def test_translate_number_decimal_positive(self): 178 | """ 179 | Test metod `translate_number` on decimal positive number. 180 | """ 181 | number = Decimal("3.14") 182 | encrypted = translate_number(self.numeric_encryption_key, number) 183 | 184 | assert_not_equal(number, encrypted) 185 | assert_equal(type(number), type(encrypted)) 186 | 187 | decrypted = translate_number(self.numeric_encryption_key, encrypted, encrypt=False) 188 | 189 | assert_equal(number, decrypted) 190 | assert_equal(type(number), type(decrypted)) 191 | 192 | def test_translate_number_decimal_negative(self): 193 | """ 194 | Test metod `translate_number` on decimal positive number. 195 | """ 196 | number = Decimal("-3.14") 197 | encrypted = translate_number(self.numeric_encryption_key, number) 198 | 199 | assert_not_equal(number, encrypted) 200 | assert_equal(type(number), type(encrypted)) 201 | 202 | decrypted = translate_number(self.numeric_encryption_key, encrypted, encrypt=False) 203 | 204 | assert_equal(number, decrypted) 205 | assert_equal(type(number), type(decrypted)) 206 | -------------------------------------------------------------------------------- /gdpr/tests/test_fields.py: -------------------------------------------------------------------------------- 1 | import json 2 | from decimal import Decimal 3 | 4 | from django.test import TestCase 5 | from django.utils import timezone 6 | 7 | from gdpr.anonymizers import ( 8 | CharFieldAnonymizer, DateFieldAnonymizer, DecimalFieldAnonymizer, EmailFieldAnonymizer, IPAddressFieldAnonymizer, 9 | StaticValueFieldAnonymizer 10 | ) 11 | from gdpr.anonymizers.fields import ( 12 | DateTimeFieldAnonymizer, FunctionFieldAnonymizer, IntegerFieldAnonymizer, JSONFieldAnonymizer, 13 | SiteIDUsernameFieldAnonymizer 14 | ) 15 | from germanium.tools import assert_dict_equal, assert_equal, assert_list_equal, assert_not_equal 16 | 17 | 18 | class TestCharField(TestCase): 19 | @classmethod 20 | def setUpTestData(cls): 21 | cls.field = CharFieldAnonymizer() 22 | cls.encryption_key = 'LoremIpsumDolorSitAmet' 23 | 24 | def test_char_field(self): 25 | name = 'John CENA' 26 | out = self.field.get_encrypted_value(name, self.encryption_key) 27 | 28 | assert_not_equal(out, name) 29 | 30 | out_decrypt = self.field.get_decrypted_value(out, self.encryption_key) 31 | 32 | assert_equal(out_decrypt, name) 33 | 34 | def test_char_field_transliteration(self): 35 | name = 'François' 36 | fixed_name = 'Francois' 37 | field = CharFieldAnonymizer(transliterate=True) 38 | out = field.get_encrypted_value(name, self.encryption_key) 39 | 40 | assert_not_equal(out, name) 41 | 42 | out_decrypt = self.field.get_decrypted_value(out, self.encryption_key) 43 | 44 | assert_equal(out_decrypt, fixed_name) 45 | 46 | def test_char_field_transliteration_full_czech(self): 47 | text = 'Příliš žluťoučký kůň úpěl ďábelské ódy' 48 | fixed_text = 'Prilis zlutoucky kun upel dabelske ody' 49 | field = CharFieldAnonymizer(transliterate=True) 50 | out = field.get_encrypted_value(text, self.encryption_key) 51 | 52 | assert_not_equal(out, text) 53 | 54 | out_decrypt = self.field.get_decrypted_value(out, self.encryption_key) 55 | 56 | assert_equal(out_decrypt, fixed_text) 57 | 58 | 59 | class TestEmailField(TestCase): 60 | @classmethod 61 | def setUpTestData(cls): 62 | cls.field = EmailFieldAnonymizer() 63 | cls.encryption_key = 'LoremIpsumDolorSitAmet' 64 | 65 | def test_email_field(self): 66 | email = 'foo@bar.com' 67 | out = self.field.get_encrypted_value(email, self.encryption_key) 68 | 69 | assert_not_equal(out, email) 70 | 71 | out_decrypt = self.field.get_decrypted_value(out, self.encryption_key) 72 | 73 | assert_equal(out_decrypt, email) 74 | 75 | 76 | class TestSiteIDUsernameFieldAnonymizer(TestCase): 77 | @classmethod 78 | def setUpTestData(cls): 79 | cls.field = SiteIDUsernameFieldAnonymizer() 80 | cls.encryption_key = 'LoremIpsumDolorSitAmet' 81 | 82 | def test_just_email(self): 83 | email = 'foo@bar.com' 84 | out = self.field.get_encrypted_value(email, self.encryption_key) 85 | 86 | assert_not_equal(out, email) 87 | 88 | out_decrypt = self.field.get_decrypted_value(out, self.encryption_key) 89 | 90 | assert_equal(out_decrypt, email) 91 | 92 | def test_normal(self): 93 | email = '1:foo@bar.com' 94 | out = self.field.get_encrypted_value(email, self.encryption_key) 95 | 96 | assert_not_equal(out, email) 97 | 98 | out_decrypt = self.field.get_decrypted_value(out, self.encryption_key) 99 | 100 | assert_equal(out_decrypt, email) 101 | 102 | 103 | class TestDateField(TestCase): 104 | @classmethod 105 | def setUpTestData(cls): 106 | cls.field = DateFieldAnonymizer() 107 | cls.encryption_key = 'LoremIpsumDolorSitAmet' 108 | 109 | def test_date_field(self): 110 | date = timezone.now() 111 | out = self.field.get_encrypted_value(date, self.encryption_key) 112 | 113 | assert_not_equal(out, date) 114 | 115 | out_decrypt = self.field.get_decrypted_value(out, self.encryption_key) 116 | 117 | assert_equal(out_decrypt, date) 118 | 119 | 120 | class TestDateTimeField(TestCase): 121 | @classmethod 122 | def setUpTestData(cls): 123 | cls.field = DateTimeFieldAnonymizer() 124 | cls.encryption_key = 'LoremIpsumDolorSitAmet' 125 | 126 | def test_date_field(self): 127 | date = timezone.now() 128 | out = self.field.get_encrypted_value(date, self.encryption_key) 129 | 130 | assert_not_equal(out, date) 131 | 132 | out_decrypt = self.field.get_decrypted_value(out, self.encryption_key) 133 | 134 | assert_equal(out_decrypt, date) 135 | 136 | 137 | class TestDecimalField(TestCase): 138 | @classmethod 139 | def setUpTestData(cls): 140 | cls.field = DecimalFieldAnonymizer() 141 | cls.encryption_key = 'LoremIpsumDolorSitAmet' 142 | 143 | def test_decimal_field_positive(self): 144 | decimal = Decimal('3.14159265358979') 145 | out = self.field.get_encrypted_value(decimal, self.encryption_key) 146 | 147 | assert_not_equal(out, decimal) 148 | 149 | out_decrypt = self.field.get_decrypted_value(out, self.encryption_key) 150 | 151 | assert_equal(out_decrypt, decimal) 152 | 153 | def test_decimal_field_negative(self): 154 | decimal = Decimal('-3.14159265358979') 155 | out = self.field.get_encrypted_value(decimal, self.encryption_key) 156 | 157 | assert_not_equal(out, decimal) 158 | 159 | out_decrypt = self.field.get_decrypted_value(out, self.encryption_key) 160 | 161 | assert_equal(out_decrypt, decimal) 162 | 163 | 164 | class TestIntegerField(TestCase): 165 | @classmethod 166 | def setUpTestData(cls): 167 | cls.field = IntegerFieldAnonymizer() 168 | cls.encryption_key = 'LoremIpsumDolorSitAmet' 169 | 170 | def test_integer_field_positive(self): 171 | number = 42 172 | out = self.field.get_encrypted_value(number, self.encryption_key) 173 | 174 | assert_not_equal(out, number) 175 | 176 | out_decrypt = self.field.get_decrypted_value(out, self.encryption_key) 177 | 178 | assert_equal(out_decrypt, number) 179 | 180 | def test_integer_field_negative(self): 181 | number = -42 182 | out = self.field.get_encrypted_value(number, self.encryption_key) 183 | 184 | assert_not_equal(out, number) 185 | 186 | out_decrypt = self.field.get_decrypted_value(out, self.encryption_key) 187 | 188 | assert_equal(out_decrypt, number) 189 | 190 | 191 | class TestIPAddressField(TestCase): 192 | @classmethod 193 | def setUpTestData(cls): 194 | cls.field = IPAddressFieldAnonymizer() 195 | cls.encryption_key = 'LoremIpsumDolorSitAmet' 196 | 197 | def test_ip_addr_v_4_field(self): 198 | ip = '127.0.0.1' 199 | out = self.field.get_encrypted_value(ip, self.encryption_key) 200 | 201 | assert_not_equal(out, ip) 202 | 203 | out_decrypt = self.field.get_decrypted_value(out, self.encryption_key) 204 | 205 | assert_equal(out_decrypt, ip) 206 | 207 | def test_ip_addr_v_6_field(self): 208 | ip = '::1' 209 | out = self.field.get_encrypted_value(ip, self.encryption_key) 210 | 211 | assert_not_equal(out, ip) 212 | 213 | out_decrypt = self.field.get_decrypted_value(out, self.encryption_key) 214 | 215 | assert_equal(out_decrypt, ip) 216 | 217 | 218 | class TestStaticValueAnonymizer(TestCase): 219 | @classmethod 220 | def setUpTestData(cls): 221 | cls.field = StaticValueFieldAnonymizer('U_SHALL_NOT_PASS') 222 | cls.encryption_key = 'LoremIpsumDolorSitAmet' 223 | 224 | def test_static_field(self): 225 | text = 'ORANGE' 226 | out = self.field.get_encrypted_value(text, self.encryption_key) 227 | 228 | assert_not_equal(out, text) 229 | 230 | 231 | class TestFunctionField(TestCase): 232 | @classmethod 233 | def setUpTestData(cls): 234 | cls.encryption_key = 'LoremIpsumDolorSitAmet' 235 | 236 | def test_one_way_function_field(self): 237 | number = 5 238 | self.field = FunctionFieldAnonymizer(lambda x, key: x ** 2) 239 | out = self.field.get_encrypted_value(number, self.encryption_key) 240 | 241 | assert_not_equal(out, number) 242 | 243 | def test_two_way_function_field(self): 244 | number = 5 245 | self.field = FunctionFieldAnonymizer(lambda a, x, key: x + a.get_numeric_encryption_key(key), 246 | lambda a, x, key: x - a.get_numeric_encryption_key(key)) 247 | self.field.max_anonymization_range = 100 248 | out = self.field.get_encrypted_value(number, self.encryption_key) 249 | 250 | assert_not_equal(out, number) 251 | 252 | out_decrypt = self.field.get_decrypted_value(out, self.encryption_key) 253 | 254 | assert_equal(out_decrypt, number) 255 | 256 | 257 | class TestJSONFieldAnonymizer(TestCase): 258 | @classmethod 259 | def setUpTestData(cls): 260 | cls.field = JSONFieldAnonymizer() 261 | cls.encryption_key = 'LoremIpsumDolorSitAmet' 262 | 263 | def test_str_value(self): 264 | text = 'John CENA' 265 | out = self.field.anonymize_json_value(text, self.encryption_key) 266 | 267 | assert_not_equal(out, text) 268 | out_decrypt = self.field.anonymize_json_value(out, self.encryption_key, False) 269 | 270 | assert_equal(out_decrypt, text) 271 | 272 | def test_none_value(self): 273 | value = None 274 | out = self.field.anonymize_json_value(value, self.encryption_key) 275 | 276 | assert_equal(out, value) 277 | out_decrypt = self.field.anonymize_json_value(out, self.encryption_key, False) 278 | 279 | assert_equal(out_decrypt, value) 280 | 281 | def test_int_value(self): 282 | value = 158 283 | out = self.field.anonymize_json_value(value, self.encryption_key) 284 | 285 | assert_not_equal(out, value) 286 | out_decrypt = self.field.anonymize_json_value(out, self.encryption_key, False) 287 | 288 | assert_equal(out_decrypt, value) 289 | 290 | def test_int_value_overflow(self): 291 | value = 9 292 | out = self.field.anonymize_json_value(value, self.encryption_key) 293 | 294 | assert_not_equal(out, value) 295 | out_decrypt = self.field.anonymize_json_value(out, self.encryption_key, False) 296 | 297 | assert_equal(out_decrypt, value) 298 | 299 | def test_dict(self): 300 | json_dict = { 301 | 'breed': 'labrador', 302 | 'owner': { 303 | 'name': 'Bob', 304 | 'other_pets': [{'name': 'Fishy'}] 305 | }, 306 | 'age': 5, 307 | 'height': 9.5, 308 | 'is_brown': True, 309 | 'none_field': None 310 | } 311 | 312 | out = self.field.anonymize_json_value(json_dict, self.encryption_key) 313 | 314 | assert_not_equal(out, json_dict) 315 | 316 | out_decrypt = self.field.anonymize_json_value(out, self.encryption_key, False) 317 | 318 | assert_dict_equal(json_dict, out_decrypt) 319 | 320 | def test_dict_str(self): 321 | json_dict = { 322 | 'breed': 'labrador', 323 | 'owner': { 324 | 'name': 'Bob', 325 | 'other_pets': [{'name': 'Fishy'}] 326 | }, 327 | 'age': 5, 328 | 'height': 9.5, 329 | 'is_brown': True, 330 | 'none_field': None 331 | } 332 | 333 | out = json.loads(self.field.get_encrypted_value(json.dumps(json_dict), self.encryption_key)) 334 | 335 | assert_not_equal(out, json_dict) 336 | 337 | out_decrypt = json.loads(self.field.get_decrypted_value(json.dumps(out), self.encryption_key)) 338 | 339 | assert_dict_equal(json_dict, out_decrypt) 340 | 341 | def test_list(self): 342 | json_list = ['banana', 'oranges', 5, 3.14, False, None, {'name': 'Bob'}] 343 | 344 | out = self.field.anonymize_json_value(json_list, self.encryption_key) 345 | 346 | assert_not_equal(out, json_list) 347 | 348 | out_decrypt = self.field.anonymize_json_value(out, self.encryption_key, False) 349 | 350 | assert_list_equal(json_list, out_decrypt) 351 | 352 | def test_list_str(self): 353 | json_list = ['banana', 'oranges', 5, 3.14, False, None, {'name': 'Bob'}] 354 | 355 | out = json.loads(self.field.get_encrypted_value(json.dumps(json_list), self.encryption_key)) 356 | 357 | assert_not_equal(out, json_list) 358 | 359 | out_decrypt = json.loads(self.field.get_decrypted_value(json.dumps(out), self.encryption_key)) 360 | 361 | assert_list_equal(json_list, out_decrypt) 362 | -------------------------------------------------------------------------------- /gdpr/tests/test_gis_fields.py: -------------------------------------------------------------------------------- 1 | from unittest import skipIf 2 | 3 | from django.test import TestCase 4 | 5 | from gdpr.anonymizers.gis import ExperimentalGISPointFieldAnonymizer, is_gis_installed 6 | from germanium.tools import assert_not_equal, assert_tuple_equal 7 | 8 | 9 | class TestGISPointFieldAnonymizer(TestCase): 10 | @classmethod 11 | def setUpTestData(cls): 12 | cls.field = ExperimentalGISPointFieldAnonymizer(max_x_range=100, max_y_range=100) 13 | cls.encryption_key = 'LoremIpsumDolorSitAmet' 14 | 15 | @skipIf(not is_gis_installed(), 'Django GIS not available.') 16 | def test_point_base(self): 17 | from django.contrib.gis.geos import Point 18 | 19 | point = Point(1, 1) 20 | out = self.field.get_encrypted_value(point, self.encryption_key) 21 | 22 | assert_not_equal(point.tuple, out.tuple) 23 | 24 | out_decrypted = self.field.get_decrypted_value(out, self.encryption_key) 25 | 26 | assert_tuple_equal(point.tuple, out_decrypted.tuple) 27 | -------------------------------------------------------------------------------- /gdpr/tests/test_ipcypher.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from gdpr.ipcypher import decrypt_ip, decrypt_ipv4, decrypt_ipv6, derive_key, encrypt_ip, encrypt_ipv4, encrypt_ipv6 4 | from germanium.tools import assert_equal 5 | 6 | 7 | KEY_EMPTY_CLEAR = '' 8 | KEY_EMPTY = bytearray.fromhex('bb8dcd7be9a6f43b3304c640d7d7103c') 9 | 10 | KEY_PI_CLEAR = '3.141592653589793' 11 | KEY_PI = bytearray.fromhex('3705bd6c0e26a1a839898f1fa016a374') 12 | 13 | KEY_CRYPTO_CLEAR = 'crypto is not a coin' 14 | KEY_CRYPTO = bytearray.fromhex('06c4bad23a38b9e0ad9d0590b0a3d93a') 15 | 16 | KEY = 'crypto is not a coin' 17 | 18 | 19 | class TestIPCrypt(TestCase): 20 | def test_derivation_empty(self): 21 | key = derive_key(KEY_EMPTY_CLEAR) 22 | 23 | assert_equal(KEY_EMPTY, key) 24 | 25 | def test_derivation_pi(self): 26 | key = derive_key(KEY_PI_CLEAR) 27 | 28 | assert_equal(KEY_PI, key) 29 | 30 | def test_derivation_crypto(self): 31 | key = derive_key(KEY_CRYPTO_CLEAR) 32 | 33 | assert_equal(KEY_CRYPTO, key) 34 | 35 | def test_ipv4_key_1(self): 36 | clear_ip = '198.41.0.4' 37 | encrypted_ip = encrypt_ipv4(KEY, clear_ip) 38 | decrypted_ip = decrypt_ipv4(KEY, encrypted_ip) 39 | 40 | assert_equal(encrypted_ip, '139.111.117.167') 41 | assert_equal(decrypted_ip, clear_ip) 42 | 43 | def test_ipv4_key_2(self): 44 | clear_ip = '130.161.180.1' 45 | encrypted_ip = encrypt_ipv4(KEY, clear_ip) 46 | decrypted_ip = decrypt_ipv4(KEY, encrypted_ip) 47 | 48 | assert_equal(encrypted_ip, '66.235.221.231') 49 | assert_equal(decrypted_ip, clear_ip) 50 | 51 | def test_ipv4_key_3(self): 52 | clear_ip = '0.0.0.0' 53 | encrypted_ip = encrypt_ipv4(KEY, clear_ip) 54 | decrypted_ip = decrypt_ipv4(KEY, encrypted_ip) 55 | 56 | assert_equal(encrypted_ip, '203.253.152.187') 57 | assert_equal(decrypted_ip, clear_ip) 58 | 59 | def test_ipv6_key_1(self): 60 | clear_ip = '::1' 61 | encrypted_ip = encrypt_ipv6(KEY, clear_ip) 62 | decrypted_ip = decrypt_ipv6(KEY, encrypted_ip) 63 | 64 | assert_equal(encrypted_ip, 'a551:9cb0:c9b:f6e1:6112:58a:af29:3a6c') 65 | assert_equal(decrypted_ip, clear_ip) 66 | 67 | def test_ipv6_key_2(self): 68 | clear_ip = '2001:503:ba3e::2:30' 69 | encrypted_ip = encrypt_ipv6(KEY, clear_ip) 70 | decrypted_ip = decrypt_ipv6(KEY, encrypted_ip) 71 | 72 | assert_equal(encrypted_ip, '6e60:2674:2fac:d383:f9d5:dcfe:fc53:328e') 73 | assert_equal(decrypted_ip, clear_ip) 74 | 75 | def test_ipv6_key_3(self): 76 | clear_ip = '2001:db8::' 77 | encrypted_ip = encrypt_ipv6(KEY, clear_ip) 78 | decrypted_ip = decrypt_ipv6(KEY, encrypted_ip) 79 | 80 | assert_equal(encrypted_ip, 'a8f5:16c8:e2ea:23b9:748d:67a2:4107:9d2e') 81 | assert_equal(decrypted_ip, clear_ip) 82 | 83 | def test_general_function_ipv4(self): 84 | clear_ip = '198.41.0.4' 85 | encrypted_ip = encrypt_ip(KEY, clear_ip) 86 | decrypted_ip = decrypt_ip(KEY, encrypted_ip) 87 | 88 | assert_equal(encrypted_ip, '139.111.117.167') 89 | assert_equal(decrypted_ip, clear_ip) 90 | 91 | def test_general_function_ipv6(self): 92 | clear_ip = '::1' 93 | encrypted_ip = encrypt_ip(KEY, clear_ip) 94 | decrypted_ip = decrypt_ip(KEY, encrypted_ip) 95 | 96 | assert_equal(encrypted_ip, 'a551:9cb0:c9b:f6e1:6112:58a:af29:3a6c') 97 | assert_equal(decrypted_ip, clear_ip) 98 | -------------------------------------------------------------------------------- /gdpr/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Type 2 | 3 | from django.core.exceptions import FieldDoesNotExist 4 | from django.db.models import Model, QuerySet 5 | 6 | 7 | def str_to_class(class_string: str) -> Any: 8 | module_name, class_name = class_string.rsplit('.', 1) 9 | # load the module, will raise ImportError if module cannot be loaded 10 | m = __import__(module_name, globals(), locals(), [str(class_name)]) 11 | # get the class, will raise AttributeError if class cannot be found 12 | c = getattr(m, class_name) 13 | return c 14 | 15 | 16 | def get_number_guess_len(value): 17 | """ 18 | Safety measure against key getting one bigger (overflow) on decrypt e.g. (5)=1 -> 5 + 8 = 13 -> (13)=2 19 | Args: 20 | value: Number convertible to int to get it's length 21 | 22 | Returns: 23 | The even length of the whole part of the number 24 | """ 25 | guess_len = len(str(int(value))) 26 | return guess_len if guess_len % 2 != 0 else (guess_len - 1) 27 | 28 | 29 | def get_field_or_none(model: Type[Model], field_name: str): 30 | """ 31 | Use django's _meta field api to get field or return None. 32 | 33 | Args: 34 | model: The model to get the field on 35 | field_name: The name of the field 36 | 37 | Returns: 38 | The field or None 39 | 40 | """ 41 | try: 42 | return model._meta.get_field(field_name) 43 | except FieldDoesNotExist: 44 | return None 45 | 46 | 47 | """ 48 | Enable support for druids reversion fork 49 | """ 50 | 51 | 52 | def get_reversion_versions(obj: Any) -> QuerySet: 53 | from reversion.models import Version 54 | from django.contrib.contenttypes.models import ContentType 55 | 56 | return Version.objects.get_for_object(obj) 57 | 58 | 59 | def get_reversion_version_model(version) -> Type[Model]: 60 | """Get object model of the version.""" 61 | if hasattr(version, '_model'): 62 | return version._model 63 | return version.content_type.model_class() 64 | 65 | 66 | def get_reversion_local_field_dict(obj): 67 | if hasattr(obj, '_local_field_dict'): 68 | return obj._local_field_dict 69 | return obj.flat_field_dict 70 | 71 | 72 | def is_reversion_installed(): 73 | try: 74 | import reversion 75 | return True 76 | except ImportError: 77 | return False 78 | 79 | 80 | def get_all_parent_objects(obj: Model) -> List[Model]: 81 | """Return all model parent instances.""" 82 | parent_paths = [ 83 | [path_info.join_field.name for path_info in parent_path] 84 | for parent_path in 85 | [obj._meta.get_path_to_parent(parent_model) for parent_model in obj._meta.get_parent_list()] 86 | ] 87 | 88 | parent_objects = [] 89 | for parent_path in parent_paths: 90 | parent_obj = obj 91 | for path in parent_path: 92 | parent_obj = getattr(parent_obj, path, None) 93 | parent_objects.append(parent_obj) 94 | 95 | return [i for i in parent_objects if i is not None] 96 | 97 | 98 | def get_all_obj_and_parent_versions_queryset_list(obj: Model) -> List[QuerySet]: 99 | """Return list of object and its parent version querysets""" 100 | from gdpr.utils import get_reversion_versions 101 | 102 | return [get_reversion_versions(i) for i in get_all_parent_objects(obj)] + [get_reversion_versions(obj)] 103 | 104 | 105 | def get_all_obj_and_parent_versions(obj: Model) -> List[Model]: 106 | """Return list of all object and its parent versions""" 107 | return [item for sublist in get_all_obj_and_parent_versions_queryset_list(obj) for item in sublist] 108 | -------------------------------------------------------------------------------- /gdpr/version.py: -------------------------------------------------------------------------------- 1 | VERSION = (0, 2, 23) 2 | 3 | 4 | def get_version(): 5 | return '.'.join(map(str, VERSION)) 6 | -------------------------------------------------------------------------------- /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 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.test_settings') 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == '__main__': 21 | main() 22 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | import django 6 | from django.conf import settings 7 | from django.test.utils import get_runner 8 | 9 | 10 | if __name__ == "__main__": 11 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.test_settings' 12 | django.setup() 13 | failures = TestRunner = get_runner(settings)().run_tests(["tests", "gdpr"]) 14 | sys.exit(bool(failures)) 15 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max_line_length = 120 3 | exclude = venv/,.git/ 4 | ignore = F401 5 | 6 | 7 | [mypy] 8 | ignore_missing_imports = True 9 | 10 | [mypy-gdpr.ipcypher] 11 | ignore_errors = True 12 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from gdpr.version import get_version 4 | from setuptools import find_packages, setup 5 | 6 | 7 | def read(fname): 8 | return open(os.path.join(os.path.dirname(__file__), fname)).read() 9 | 10 | 11 | setup( 12 | name='django-GDPR', 13 | long_description=read('README.md'), 14 | long_description_content_type='text/markdown', 15 | version=get_version(), 16 | description='Library for GDPR implementation', 17 | author='Druids', 18 | author_email='matllubos@gmail.com', 19 | url='https://github.com/druids/django-GDPR', 20 | license='MIT', 21 | package_dir={'gdpr': 'gdpr'}, 22 | include_package_data=True, 23 | packages=find_packages(), 24 | classifiers=[ 25 | 'Development Status :: 3 - Alpha', 26 | 'Framework :: Django', 27 | 'Framework :: Django :: 1.10', 28 | 'Framework :: Django :: 1.11', 29 | 'Framework :: Django :: 2.0', 30 | 'Framework :: Django :: 2.1', 31 | 'Intended Audience :: Developers', 32 | 'License :: OSI Approved :: MIT License', 33 | 'Operating System :: OS Independent', 34 | 'Programming Language :: Python', 35 | 'Programming Language :: Python :: 3.6', 36 | 'Programming Language :: Python :: 3.7', 37 | 'Programming Language :: Python :: 3 :: Only', 38 | 'Intended Audience :: Developers', 39 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 40 | ], 41 | install_requires=[ 42 | 'django>=2.2,<4.0', 43 | 'django-chamber>=0.6.6', 44 | 'tqdm>=4.28.1', 45 | 'pyaes>=1.6.1', 46 | 'unidecode', 47 | 'django-choice-enumfields>=1.1.0', 48 | ], 49 | zip_safe=False, 50 | ) 51 | -------------------------------------------------------------------------------- /test_file: -------------------------------------------------------------------------------- 1 | Random chaos 2 | -------------------------------------------------------------------------------- /test_requirements.txt: -------------------------------------------------------------------------------- 1 | flake8==3.8.4 2 | mypy==0.790 3 | python-dateutil==2.7.5 4 | django-extensions 5 | freezegun==0.3.12 6 | Faker==1.0.1 7 | django-reversion==3.0.8 8 | django-germanium 9 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/druids/django-GDPR/1595a652651100d5a1809c83a1516e5657cf2a6b/tests/__init__.py -------------------------------------------------------------------------------- /tests/anonymizers.py: -------------------------------------------------------------------------------- 1 | from gdpr import anonymizers 2 | from gdpr.anonymizers.local import cs 3 | from tests.models import ( 4 | Account, Address, ContactForm, Customer, CustomerRegistration, Email, Note, Payment, Avatar, ChildE 5 | ) 6 | 7 | 8 | class CustomerRegistrationAnonymizer(anonymizers.ModelAnonymizer): 9 | email_address = anonymizers.EmailFieldAnonymizer() 10 | 11 | class Meta: 12 | model = Customer 13 | 14 | 15 | class CustomerAnonymizer(anonymizers.ModelAnonymizer): 16 | first_name = anonymizers.MD5TextFieldAnonymizer() 17 | last_name = anonymizers.MD5TextFieldAnonymizer() 18 | primary_email_address = anonymizers.EmailFieldAnonymizer() 19 | 20 | full_name = anonymizers.CharFieldAnonymizer() 21 | birth_date = anonymizers.DateFieldAnonymizer() 22 | personal_id = cs.CzechPersonalIDSmartFieldAnonymizer() 23 | phone_number = cs.CzechPhoneNumberFieldAnonymizer() 24 | facebook_id = anonymizers.CharFieldAnonymizer() 25 | last_login_ip = anonymizers.IPAddressFieldAnonymizer() 26 | last_registration = CustomerRegistrationAnonymizer() 27 | other_registrations = CustomerRegistrationAnonymizer() 28 | 29 | notes = anonymizers.ReverseGenericRelationAnonymizer('tests', 'Note') 30 | 31 | def get_encryption_key(self, obj: Customer): 32 | return (f"{(obj.first_name or '').strip()}::{(obj.last_name or '').strip()}::" 33 | f"{(obj.primary_email_address or '').strip()}") 34 | 35 | class Meta: 36 | model = Customer 37 | 38 | 39 | class EmailAnonymizer(anonymizers.ModelAnonymizer): 40 | email = anonymizers.EmailFieldAnonymizer() 41 | 42 | class Meta: 43 | model = Email 44 | 45 | 46 | class AddressAnonymizer(anonymizers.ModelAnonymizer): 47 | street = anonymizers.CharFieldAnonymizer() 48 | 49 | class Meta: 50 | model = Address 51 | 52 | 53 | class AccountAnonymizer(anonymizers.ModelAnonymizer): 54 | number = cs.CzechAccountNumberFieldAnonymizer(use_smart_method=True) 55 | IBAN = cs.CzechIBANSmartFieldAnonymizer() 56 | owner = anonymizers.CharFieldAnonymizer() 57 | 58 | class Meta: 59 | model = Account 60 | 61 | 62 | class PaymentAnonymizer(anonymizers.ModelAnonymizer): 63 | value = anonymizers.DecimalFieldAnonymizer() 64 | date = anonymizers.DateFieldAnonymizer() 65 | 66 | class Meta: 67 | model = Payment 68 | 69 | 70 | class ContactFormAnonymizer(anonymizers.ModelAnonymizer): 71 | email = anonymizers.EmailFieldAnonymizer() 72 | full_name = anonymizers.CharFieldAnonymizer() 73 | 74 | class Meta: 75 | model = ContactForm 76 | reversible_anonymization = False 77 | 78 | 79 | class NoteAnonymizer(anonymizers.ModelAnonymizer): 80 | note = anonymizers.CharFieldAnonymizer() 81 | contact_form = anonymizers.GenericRelationAnonymizer('tests', 'ContactForm') 82 | 83 | class Meta: 84 | model = Note 85 | 86 | 87 | class AvatarAnonymizer(anonymizers.ModelAnonymizer): 88 | image = anonymizers.ReplaceFileFieldAnonymizer() 89 | 90 | class Meta: 91 | model = Avatar 92 | 93 | 94 | class ChildEAnonymizer(anonymizers.ModelAnonymizer): 95 | name = anonymizers.CharFieldAnonymizer() 96 | first_name = anonymizers.CharFieldAnonymizer() 97 | last_name = anonymizers.CharFieldAnonymizer() 98 | birth_date = anonymizers.DateFieldAnonymizer() 99 | note = anonymizers.CharFieldAnonymizer() 100 | 101 | class Meta: 102 | model = ChildE 103 | anonymize_reversion = True 104 | -------------------------------------------------------------------------------- /tests/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.5 on 2019-01-07 16:35 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | import gdpr.mixins 7 | 8 | 9 | class Migration(migrations.Migration): 10 | initial = True 11 | 12 | dependencies = [] # type: ignore 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='Account', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('number', models.CharField(blank=True, max_length=256, null=True)), 20 | ('owner', models.CharField(blank=True, max_length=256, null=True)), 21 | ], 22 | options={ 23 | 'abstract': False, 24 | }, 25 | bases=(gdpr.mixins.AnonymizationModelMixin, models.Model), 26 | ), 27 | migrations.CreateModel( 28 | name='Address', 29 | fields=[ 30 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 31 | ('street', models.CharField(blank=True, max_length=256, null=True)), 32 | ('house_number', models.CharField(blank=True, max_length=20, null=True)), 33 | ('city', models.CharField(blank=True, max_length=256, null=True)), 34 | ('post_code', models.CharField(blank=True, max_length=6, null=True)), 35 | ], 36 | options={ 37 | 'abstract': False, 38 | }, 39 | bases=(gdpr.mixins.AnonymizationModelMixin, models.Model), 40 | ), 41 | migrations.CreateModel( 42 | name='ContactForm', 43 | fields=[ 44 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 45 | ('email', models.EmailField(max_length=254)), 46 | ('full_name', models.CharField(max_length=256)), 47 | ], 48 | options={ 49 | 'abstract': False, 50 | }, 51 | bases=(gdpr.mixins.AnonymizationModelMixin, models.Model), 52 | ), 53 | migrations.CreateModel( 54 | name='Customer', 55 | fields=[ 56 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 57 | ('first_name', models.CharField(max_length=256)), 58 | ('last_name', models.CharField(max_length=256)), 59 | ('primary_email_address', models.EmailField(blank=True, max_length=254, null=True)), 60 | ('full_name', models.CharField(blank=True, max_length=256, null=True)), 61 | ('birth_date', models.DateField(blank=True, null=True)), 62 | ('personal_id', models.CharField(blank=True, max_length=10, null=True)), 63 | ('phone_number', models.CharField(blank=True, max_length=9, null=True)), 64 | ('fb_id', models.CharField(blank=True, max_length=256, null=True)), 65 | ('last_login_ip', models.GenericIPAddressField(blank=True, null=True)), 66 | ], 67 | options={ 68 | 'abstract': False, 69 | }, 70 | bases=(gdpr.mixins.AnonymizationModelMixin, models.Model), 71 | ), 72 | migrations.CreateModel( 73 | name='Email', 74 | fields=[ 75 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 76 | ('email', models.EmailField(blank=True, max_length=254, null=True)), 77 | ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', 78 | to='tests.Customer')), 79 | ], 80 | options={ 81 | 'abstract': False, 82 | }, 83 | bases=(gdpr.mixins.AnonymizationModelMixin, models.Model), 84 | ), 85 | migrations.CreateModel( 86 | name='Payment', 87 | fields=[ 88 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 89 | ('value', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), 90 | ('date', models.DateField(auto_now_add=True)), 91 | ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', 92 | to='tests.Account')), 93 | ], 94 | options={ 95 | 'abstract': False, 96 | }, 97 | bases=(gdpr.mixins.AnonymizationModelMixin, models.Model), 98 | ), 99 | migrations.AddField( 100 | model_name='address', 101 | name='customer', 102 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='addresses', 103 | to='tests.Customer'), 104 | ), 105 | migrations.AddField( 106 | model_name='account', 107 | name='customer', 108 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accounts', 109 | to='tests.Customer'), 110 | ), 111 | ] 112 | -------------------------------------------------------------------------------- /tests/migrations/0002_note.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.5 on 2019-02-08 09:38 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import gdpr.mixins 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ('contenttypes', '0002_remove_content_type_name'), 11 | ('tests', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='Note', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('note', models.TextField()), 20 | ('object_id', models.PositiveIntegerField()), 21 | ('content_type', 22 | models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), 23 | ], 24 | options={ 25 | 'abstract': False, 26 | }, 27 | bases=(gdpr.mixins.AnonymizationModelMixin, models.Model), 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /tests/migrations/0003_IBAN.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.5 on 2019-02-08 17:48 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ('tests', '0002_note'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name='account', 14 | name='IBAN', 15 | field=models.CharField(blank=True, max_length=34, null=True), 16 | ), 17 | migrations.AddField( 18 | model_name='account', 19 | name='swift', 20 | field=models.CharField(blank=True, max_length=11, null=True), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /tests/migrations/0004_facebook_id.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.8 on 2019-02-25 16:27 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ('tests', '0003_IBAN'), 11 | ] 12 | 13 | operations = [ 14 | migrations.RemoveField( 15 | model_name='customer', 16 | name='fb_id', 17 | ), 18 | migrations.AddField( 19 | model_name='customer', 20 | name='facebook_id', 21 | field=models.CharField(blank=True, help_text='Facebook ID used for login via Facebook.', max_length=256, 22 | null=True, verbose_name='Facebook ID'), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /tests/migrations/0005_avatar.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.8 on 2019-02-27 14:44 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | import gdpr.mixins 8 | import tests.validators 9 | 10 | 11 | class Migration(migrations.Migration): 12 | dependencies = [ 13 | ('tests', '0004_facebook_id'), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Avatar', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('image', models.FileField(upload_to='')), 22 | ], 23 | options={ 24 | 'abstract': False, 25 | }, 26 | bases=(gdpr.mixins.AnonymizationModelMixin, models.Model), 27 | ), 28 | migrations.AlterField( 29 | model_name='account', 30 | name='number', 31 | field=models.CharField(blank=True, max_length=256, null=True, 32 | validators=[tests.validators.BankAccountValidator]), 33 | ), 34 | migrations.AlterField( 35 | model_name='customer', 36 | name='personal_id', 37 | field=models.CharField(blank=True, max_length=10, null=True, 38 | validators=[tests.validators.CZBirthNumberValidator]), 39 | ), 40 | migrations.AddField( 41 | model_name='avatar', 42 | name='custormer', 43 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='avatars', 44 | to='tests.Customer'), 45 | ), 46 | ] 47 | -------------------------------------------------------------------------------- /tests/migrations/0006_auto_20190305_0323.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.8 on 2019-03-05 09:23 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('tests', '0005_avatar'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RenameField( 16 | model_name='avatar', 17 | old_name='custormer', 18 | new_name='customer', 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /tests/migrations/0007_childe_extraparentd_parentb_parentc_topparenta.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.7 on 2019-03-20 10:06 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import gdpr.mixins 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ('tests', '0006_auto_20190305_0323'), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='ExtraParentD', 16 | fields=[ 17 | ('id_d', models.AutoField(editable=False, primary_key=True, serialize=False)), 18 | ('note', models.CharField(max_length=250)), 19 | ], 20 | options={ 21 | 'abstract': False, 22 | }, 23 | bases=(gdpr.mixins.AnonymizationModelMixin, models.Model), 24 | ), 25 | migrations.CreateModel( 26 | name='TopParentA', 27 | fields=[ 28 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 29 | ('name', models.CharField(max_length=250)), 30 | ], 31 | options={ 32 | 'abstract': False, 33 | }, 34 | bases=(gdpr.mixins.AnonymizationModelMixin, models.Model), 35 | ), 36 | migrations.CreateModel( 37 | name='ParentB', 38 | fields=[ 39 | ('topparenta_ptr', 40 | models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, 41 | primary_key=True, serialize=False, to='tests.TopParentA')), 42 | ('birth_date', models.DateField()), 43 | ], 44 | options={ 45 | 'abstract': False, 46 | }, 47 | bases=('tests.topparenta',), 48 | ), 49 | migrations.CreateModel( 50 | name='ParentC', 51 | fields=[ 52 | ('parentb_ptr', 53 | models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, 54 | primary_key=True, serialize=False, to='tests.ParentB')), 55 | ('first_name', models.CharField(max_length=250)), 56 | ], 57 | options={ 58 | 'abstract': False, 59 | }, 60 | bases=('tests.parentb',), 61 | ), 62 | migrations.CreateModel( 63 | name='ChildE', 64 | fields=[ 65 | ('extraparentd_ptr', 66 | models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, 67 | to='tests.ExtraParentD')), 68 | ('parentc_ptr', 69 | models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, 70 | primary_key=True, serialize=False, to='tests.ParentC')), 71 | ('last_name', models.CharField(max_length=250)), 72 | ], 73 | options={ 74 | 'abstract': False, 75 | }, 76 | bases=('tests.parentc', 'tests.extraparentd'), 77 | ), 78 | ] 79 | -------------------------------------------------------------------------------- /tests/migrations/0008_customerregistration.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1 on 2021-04-16 11:47 2 | 3 | from django.db import migrations, models 4 | import gdpr.mixins 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('tests', '0007_childe_extraparentd_parentb_parentc_topparenta'), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='CustomerRegistration', 16 | fields=[ 17 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('email_address', models.EmailField(blank=True, max_length=254, null=True)), 19 | ], 20 | options={ 21 | 'abstract': False, 22 | }, 23 | bases=(gdpr.mixins.AnonymizationModelMixin, models.Model), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /tests/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/druids/django-GDPR/1595a652651100d5a1809c83a1516e5657cf2a6b/tests/migrations/__init__.py -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | Models for test app: 3 | 4 | Customer 5 | - Email 6 | - Address 7 | - AccountNumber 8 | - Payment 9 | 10 | """ 11 | from django.contrib.contenttypes.fields import GenericForeignKey 12 | from django.contrib.contenttypes.models import ContentType 13 | from django.db import models 14 | from django.utils.translation import gettext_lazy as _ 15 | 16 | from gdpr.mixins import AnonymizationModel 17 | from gdpr.utils import is_reversion_installed 18 | from tests.validators import CZBirthNumberValidator, BankAccountValidator 19 | 20 | 21 | class CustomerRegistration(AnonymizationModel): 22 | email_address = models.EmailField(blank=True, null=True) 23 | 24 | 25 | class Customer(AnonymizationModel): 26 | # Keys for pseudoanonymization 27 | first_name = models.CharField(max_length=256) 28 | last_name = models.CharField(max_length=256) 29 | primary_email_address = models.EmailField(blank=True, null=True) 30 | 31 | full_name = models.CharField(max_length=256, blank=True, null=True) 32 | birth_date = models.DateField(blank=True, null=True) 33 | personal_id = models.CharField(max_length=10, blank=True, null=True, validators=[CZBirthNumberValidator]) 34 | phone_number = models.CharField(max_length=9, blank=True, null=True) 35 | facebook_id = models.CharField( 36 | max_length=256, blank=True, null=True, 37 | verbose_name=_("Facebook ID"), help_text=_("Facebook ID used for login via Facebook.")) 38 | last_login_ip = models.GenericIPAddressField(blank=True, null=True) 39 | 40 | @property 41 | def other_registrations(self): 42 | return CustomerRegistration.objects.filter(email_address=self.primary_email_address).order_by('-pk')[1:] 43 | 44 | @property 45 | def last_registration(self): 46 | return CustomerRegistration.objects.filter(email_address=self.primary_email_address).order_by('pk').last() 47 | 48 | def save(self, *args, **kwargs): 49 | """Just helper method for saving full name. 50 | 51 | You can ignore this method. 52 | """ 53 | self.full_name = "%s %s" % (self.first_name, self.last_name) 54 | super().save(*args, **kwargs) 55 | 56 | def __str__(self): 57 | return f"{self.first_name} {self.last_name}" 58 | 59 | 60 | class Email(AnonymizationModel): 61 | """Example on anonymization on related field.""" 62 | customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name="emails") 63 | email = models.EmailField(blank=True, null=True) 64 | 65 | 66 | class Address(AnonymizationModel): 67 | customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name="addresses") 68 | street = models.CharField(max_length=256, blank=True, null=True) 69 | house_number = models.CharField(max_length=20, blank=True, null=True) 70 | city = models.CharField(max_length=256, blank=True, null=True) 71 | post_code = models.CharField(max_length=6, blank=True, null=True) 72 | 73 | 74 | class Account(AnonymizationModel): 75 | customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name="accounts") 76 | number = models.CharField(max_length=256, blank=True, null=True, validators=[BankAccountValidator]) 77 | IBAN = models.CharField(max_length=34, blank=True, null=True) 78 | swift = models.CharField(max_length=11, blank=True, null=True) 79 | owner = models.CharField(max_length=256, blank=True, null=True) 80 | 81 | 82 | class Payment(AnonymizationModel): 83 | """Down the rabbit hole multilevel relations.""" 84 | account = models.ForeignKey(Account, on_delete=models.CASCADE, related_name="payments") 85 | value = models.DecimalField(blank=True, null=True, decimal_places=2, max_digits=10) 86 | date = models.DateField(auto_now_add=True) 87 | 88 | 89 | class ContactForm(AnonymizationModel): 90 | email = models.EmailField() 91 | full_name = models.CharField(max_length=256) 92 | 93 | 94 | class Note(AnonymizationModel): 95 | note = models.TextField() 96 | content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) 97 | object_id = models.PositiveIntegerField() 98 | content_object = GenericForeignKey('content_type', 'object_id') 99 | 100 | 101 | class Avatar(AnonymizationModel): 102 | customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name="avatars") 103 | image = models.FileField() 104 | 105 | 106 | class TopParentA(AnonymizationModel): 107 | name = models.CharField(max_length=250) 108 | 109 | 110 | class ParentB(TopParentA): 111 | birth_date = models.DateField() 112 | 113 | 114 | class ParentC(ParentB): 115 | first_name = models.CharField(max_length=250) 116 | 117 | 118 | class ExtraParentD(AnonymizationModel): 119 | id_d = models.AutoField(primary_key=True, editable=False) 120 | note = models.CharField(max_length=250) 121 | 122 | 123 | class ChildE(ParentC, ExtraParentD): 124 | last_name = models.CharField(max_length=250) 125 | 126 | 127 | if is_reversion_installed(): 128 | from reversion import revisions as reversion 129 | 130 | reversion.register(Customer) 131 | reversion.register(Email) 132 | reversion.register(Address) 133 | reversion.register(Account) 134 | reversion.register(Payment) 135 | reversion.register(ContactForm) 136 | reversion.register(Note) 137 | reversion.register(TopParentA) 138 | reversion.register(ParentB, follow=('topparenta_ptr',)) 139 | reversion.register(ParentC, follow=('parentb_ptr',)) 140 | reversion.register(ExtraParentD) 141 | reversion.register(ChildE, follow=('parentc_ptr', 'extraparentd_ptr')) 142 | -------------------------------------------------------------------------------- /tests/purposes.py: -------------------------------------------------------------------------------- 1 | from dateutil.relativedelta import relativedelta 2 | 3 | from gdpr.purposes.default import AbstractPurpose 4 | 5 | from .models import Customer 6 | 7 | 8 | # SLUG can be any length up to 100 characters 9 | FIRST_AND_LAST_NAME_SLUG = "FNL" 10 | EMAIL_SLUG = "EML" 11 | PAYMENT_VALUE_SLUG = "PVL" 12 | ACCOUNT_SLUG = "ACC" 13 | ACCOUNT_AND_PAYMENT_SLUG = "ACP" 14 | ADDRESS_SLUG = "ADD" 15 | CONTACT_FORM_SLUG = "CTF" 16 | EVERYTHING_SLUG = "EVR" 17 | MARKETING_SLUG = "MKT" 18 | FACEBOOK_SLUG = "FACEBOOK" 19 | 20 | 21 | class FirstNLastNamePurpose(AbstractPurpose): 22 | """Store First & Last name for 10 years.""" 23 | name = "retain due to internet archive" 24 | slug = FIRST_AND_LAST_NAME_SLUG 25 | expiration_timedelta = relativedelta(years=10) 26 | fields = ("first_name", "last_name") 27 | 28 | 29 | class EmailsPurpose(AbstractPurpose): 30 | """Store emails for 5 years.""" 31 | name = "retain due to over cat overlords" 32 | slug = EMAIL_SLUG 33 | expiration_timedelta = relativedelta(years=5) 34 | fields = ( 35 | ("emails", ( 36 | "email", 37 | )), 38 | ("other_registrations", ( 39 | 'email_address', 40 | )), 41 | ("last_registration", ( 42 | 'email_address', 43 | )), 44 | ) 45 | 46 | 47 | class PaymentValuePurpose(AbstractPurpose): 48 | name = "retain due to Foo bar" 49 | slug = PAYMENT_VALUE_SLUG 50 | expiration_timedelta = relativedelta(months=6) 51 | fields = ( 52 | ("accounts", ( 53 | ("payments", ( 54 | "value", 55 | )), 56 | )), 57 | ) 58 | 59 | 60 | class AccountPurpose(AbstractPurpose): 61 | name = "retain due to Lorem ipsum" 62 | slug = ACCOUNT_SLUG 63 | expiration_timedelta = relativedelta(years=2) 64 | fields = ( 65 | ("accounts", ( 66 | "number", 67 | "owner" 68 | )), 69 | ) 70 | 71 | 72 | class AccountsAndPaymentsPurpose(AbstractPurpose): 73 | name = "retain due to Gandalf" 74 | slug = ACCOUNT_AND_PAYMENT_SLUG 75 | expiration_timedelta = relativedelta(years=3) 76 | fields = ( 77 | ("accounts", ( 78 | "number", 79 | "owner", 80 | ("payments", ( 81 | "value", 82 | "date" 83 | )), 84 | )), 85 | ) 86 | 87 | 88 | class AddressPurpose(AbstractPurpose): 89 | name = "retain due to why not?" 90 | slug = ADDRESS_SLUG 91 | expiration_timedelta = relativedelta(years=1) 92 | fields = ( 93 | ("addresses", ( 94 | "street", 95 | "house_number" 96 | )), 97 | ) 98 | 99 | 100 | class EverythingPurpose(AbstractPurpose): 101 | name = "retain due to Area 51" 102 | slug = EVERYTHING_SLUG 103 | expiration_timedelta = relativedelta(years=51) 104 | fields = ( 105 | "__ALL__", 106 | ("addresses", "__ALL__"), 107 | ("accounts", ( 108 | "__ALL__", 109 | ("payments", ( 110 | "__ALL__", 111 | )) 112 | )), 113 | ("emails", ( 114 | "__ALL__", 115 | )), 116 | ) 117 | 118 | 119 | class ContactFormPurpose(AbstractPurpose): 120 | name = "retain due to mailing campaign" 121 | slug = CONTACT_FORM_SLUG 122 | expiration_timedelta = relativedelta(months=1) 123 | fields = "__ALL__" 124 | 125 | 126 | class MarketingPurpose(AbstractPurpose): 127 | """retain due customers wanting more adds""" 128 | name = "retain due customers wanting more adds" 129 | slug = MARKETING_SLUG 130 | expiration_timedelta = relativedelta(years=1) 131 | fields = () 132 | source_model_class = Customer 133 | 134 | 135 | class FacebookPurpose(AbstractPurpose): 136 | name = "retain due to facebook ID" 137 | slug = FACEBOOK_SLUG 138 | expiration_timedelta = relativedelta(years=5) 139 | fields = "__ALL__" 140 | 141 | source_model_class = 'tests.Customer' 142 | 143 | def can_anonymize_obj(self, obj, fields): 144 | return obj.facebook_id is not None 145 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | """Minimal django settings to run tests.""" 2 | from gdpr.utils import is_reversion_installed 3 | 4 | DEBUG = True 5 | SECRET_KEY = 'fake-key' 6 | GDPR_KEY = "GDPR_TEST_KEY_PLEASE_CHANGE-#!6_c+78r-q6)@9@z14=3a754929d600$3ll3)(8h0n@cjc*-CHANGE_ME" 7 | INSTALLED_APPS = [ 8 | "django.contrib.contenttypes", 9 | "gdpr", 10 | "tests", 11 | "django_extensions", 12 | # Requirements for django-reversion below 13 | "django.contrib.auth", 14 | "django.contrib.admin", 15 | "django.contrib.messages", 16 | "django.contrib.sessions", 17 | ] 18 | 19 | if is_reversion_installed(): 20 | INSTALLED_APPS += ["reversion"] 21 | 22 | DATABASES = { 23 | 'default': { 24 | 'ENGINE': 'django.db.backends.sqlite3', 25 | 'NAME': 'db.sqlite3', 26 | } 27 | } 28 | 29 | TEMPLATES = [ 30 | { 31 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 32 | 'DIRS': [ 33 | # insert your TEMPLATE_DIRS here 34 | ], 35 | 'APP_DIRS': True, 36 | 'OPTIONS': { 37 | 'context_processors': [ 38 | # Insert your TEMPLATE_CONTEXT_PROCESSORS here or use this 39 | # list if you haven't customized them: 40 | 'django.contrib.auth.context_processors.auth', 41 | 'django.template.context_processors.debug', 42 | 'django.template.context_processors.i18n', 43 | 'django.template.context_processors.request', 44 | 'django.template.context_processors.media', 45 | 'django.template.context_processors.static', 46 | 'django.template.context_processors.tz', 47 | 'django.contrib.messages.context_processors.messages', 48 | ], 49 | }, 50 | }, 51 | ] 52 | 53 | MIDDLEWARE = MIDDLEWARE_CLASSES = ( 54 | 'django.middleware.common.CommonMiddleware', 55 | 'django.contrib.sessions.middleware.SessionMiddleware', 56 | 'django.middleware.csrf.CsrfViewMiddleware', 57 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 58 | 'django.contrib.messages.middleware.MessageMiddleware', 59 | ) 60 | 61 | USE_TZ = True 62 | 63 | # Old legacy settings 64 | # @TODO: Replace 65 | ANONYMIZATION_NAME_KEY = "SOMEFAKEKEY" 66 | ANONYMIZATION_PERSONAL_ID_KEY = 256 67 | ANONYMIZATION_PHONE_KEY = 21212 68 | -------------------------------------------------------------------------------- /tests/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/druids/django-GDPR/1595a652651100d5a1809c83a1516e5657cf2a6b/tests/tests/__init__.py -------------------------------------------------------------------------------- /tests/tests/data.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | from decimal import Decimal 3 | 4 | 5 | CUSTOMER__FIRST_NAME = "John" 6 | CUSTOMER__LAST_NAME = "Smith" 7 | CUSTOMER__EMAIL = "smth@u.plus" 8 | CUSTOMER__EMAIL2 = "foo@u.plus" 9 | CUSTOMER__EMAIL3 = "bar@u.plus" 10 | CUSTOMER__BIRTH_DATE = date(2000, 1, 1) 11 | CUSTOMER__PERSONAL_ID = "0001010004" 12 | CUSTOMER__PHONE_NUMBER = "605222111" 13 | CUSTOMER__FACEBOOK_ID = "4" 14 | CUSTOMER__IP = "127.0.0.1" 15 | 16 | CUSTOMER__KWARGS = { 17 | "first_name": CUSTOMER__FIRST_NAME, 18 | "last_name": CUSTOMER__LAST_NAME, 19 | "primary_email_address": CUSTOMER__EMAIL, 20 | "birth_date": CUSTOMER__BIRTH_DATE, 21 | "personal_id": CUSTOMER__PERSONAL_ID, 22 | "phone_number": CUSTOMER__PHONE_NUMBER, 23 | "facebook_id": CUSTOMER__FACEBOOK_ID, 24 | "last_login_ip": CUSTOMER__IP 25 | } 26 | 27 | ADDRESS__STREET = "Downing Street" 28 | ADDRESS__HOUSE_NUMBER = "10" 29 | ADDRESS__CITY = "London" 30 | ADDRESS__POST_CODE = "SW1A 2AB" 31 | 32 | ACCOUNT__NUMBER = "19-2000145399/0800" 33 | ACCOUNT__IBAN = "CZ65 0800 0000 1920 0014 5399" 34 | ACCOUNT__SWIFT = "GIBACZPX" 35 | ACCOUNT__OWNER = "John Smith" 36 | 37 | ACCOUNT__NUMBER2 = "2501277007/2010" 38 | ACCOUNT__IBAN2 = "CZ54 2010 0000 0025 0127 7007" 39 | ACCOUNT__SWIFT2 = "FIOBCZPP" 40 | ACCOUNT__OWNER2 = "James Bond" 41 | 42 | PAYMENT__VALUE = Decimal("10.1") 43 | -------------------------------------------------------------------------------- /tests/tests/test_deactivate_expired_reasons.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from dateutil.relativedelta import relativedelta 4 | 5 | from django.test import TestCase, override_settings 6 | 7 | from freezegun import freeze_time 8 | from gdpr.models import LegalReason 9 | from germanium.tools import assert_equal, assert_false, assert_not_equal, assert_true, test_call_command 10 | from tests.models import Customer 11 | from tests.purposes import FIRST_AND_LAST_NAME_SLUG, MARKETING_SLUG 12 | from tests.tests.data import CUSTOMER__FIRST_NAME, CUSTOMER__KWARGS 13 | from tests.tests.utils import AnonymizedDataMixin 14 | 15 | 16 | class TestDeactivateExpiredReasons(AnonymizedDataMixin, TestCase): 17 | 18 | @classmethod 19 | def setUpTestData(cls): 20 | cls.customer: Customer = Customer(**CUSTOMER__KWARGS) 21 | cls.customer.save() 22 | 23 | def test_command_should_expire_active_legal_reson(self): 24 | legal_reason = LegalReason.objects.create_consent(MARKETING_SLUG, self.customer) 25 | legal_reason.save() 26 | 27 | # consent has not expired 28 | test_call_command('deactivate_expired_reasons') 29 | assert_true(LegalReason.objects.exists_valid_consent(MARKETING_SLUG, self.customer)) 30 | 31 | # consent has expired 32 | with freeze_time(legal_reason.expires_at + relativedelta(seconds=1)): 33 | test_call_command('deactivate_expired_reasons') 34 | assert_false(LegalReason.objects.exists_valid_consent(MARKETING_SLUG, self.customer)) 35 | 36 | def test_command_should_not_expire_deactivated_legal_reasons(self): 37 | legal_reason = LegalReason.objects.create_consent(MARKETING_SLUG, self.customer) 38 | legal_reason.save() 39 | legal_reason.deactivate() 40 | 41 | # consent has not expired 42 | test_call_command('deactivate_expired_reasons') 43 | assert_true(LegalReason.objects.exists_deactivated_consent(MARKETING_SLUG, self.customer)) 44 | 45 | # consent has expired 46 | with freeze_time(legal_reason.expires_at + relativedelta(seconds=1)): 47 | test_call_command('deactivate_expired_reasons') 48 | assert_true(LegalReason.objects.exists_deactivated_consent(MARKETING_SLUG, self.customer)) 49 | 50 | def test_command_should_not_touch_already_expired_legal_reasons(self): 51 | legal_reason = LegalReason.objects.create_consent(MARKETING_SLUG, self.customer) 52 | legal_reason.save() 53 | legal_reason.expire() 54 | original_expiration_time = legal_reason.changed_at 55 | 56 | with freeze_time(original_expiration_time + relativedelta(seconds=1)): 57 | test_call_command('deactivate_expired_reasons') 58 | assert_equal(original_expiration_time, legal_reason.refresh_from_db().changed_at) 59 | 60 | def test_command_should_not_anonymize_customer_if_expiring_reason_is_not_related_to_customer_data(self): 61 | marketing_legal_reason = LegalReason.objects.create_consent(MARKETING_SLUG, self.customer) 62 | marketing_legal_reason.save() 63 | LegalReason.objects.create_consent(FIRST_AND_LAST_NAME_SLUG, self.customer).save() 64 | 65 | # none of the consents have expired 66 | test_call_command('deactivate_expired_reasons') 67 | assert_true(LegalReason.objects.exists_valid_consent(MARKETING_SLUG, self.customer)) 68 | assert_true(LegalReason.objects.exists_valid_consent(FIRST_AND_LAST_NAME_SLUG, self.customer)) 69 | 70 | # marketing consent has expired 71 | with freeze_time(marketing_legal_reason.expires_at + relativedelta(seconds=1)): 72 | test_call_command('deactivate_expired_reasons') 73 | assert_false(LegalReason.objects.exists_valid_consent(MARKETING_SLUG, self.customer)) 74 | assert_true(LegalReason.objects.exists_valid_consent(FIRST_AND_LAST_NAME_SLUG, self.customer)) 75 | assert_equal(self.customer.first_name, CUSTOMER__FIRST_NAME) 76 | self.assertAnonymizedDataNotExists(self.customer, 'first_name') 77 | assert_not_equal(self.customer._anonymize_obj(), CUSTOMER__FIRST_NAME) 78 | self.assertAnonymizedDataExists(self.customer, 'first_name') 79 | 80 | @override_settings(GDPR_DEACTIVATE_EXPIRED_REASONS_CHUNK_SIZE=1) 81 | def test_command_should_support_chunking(self): 82 | legal_reason1 = LegalReason.objects.create_consent(MARKETING_SLUG, self.customer) 83 | legal_reason1.save() 84 | legal_reason2 = LegalReason.objects.create_consent(FIRST_AND_LAST_NAME_SLUG, self.customer) 85 | legal_reason2.save() 86 | 87 | with freeze_time(max(legal_reason1.expires_at, legal_reason2.expires_at) + relativedelta(seconds=1)): 88 | with patch('gdpr.management.commands.deactivate_expired_reasons.logger.info') as logger_mock: 89 | assert_equal(LegalReason.objects.filter_active().count(), 2) 90 | test_call_command('deactivate_expired_reasons') 91 | assert_equal(LegalReason.objects.filter_active().count(), 1) 92 | test_call_command('deactivate_expired_reasons') 93 | assert_equal(LegalReason.objects.filter_active().count(), 0) 94 | logger_mock.assert_called_once_with('Command "deactivate_expired_reasons" finished') 95 | -------------------------------------------------------------------------------- /tests/tests/test_empty.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from germanium.tools import assert_true 4 | 5 | 6 | class EmptyTests(TestCase): 7 | def test_this_test_is_run(self): 8 | """Test that the test suite is running this tests.""" 9 | assert_true(True) 10 | 11 | def test_GDPR_app_is_reachable(self): 12 | """Test that GDPR app is reachable.""" 13 | from gdpr.version import get_version 14 | get_version() 15 | assert_true(True) 16 | -------------------------------------------------------------------------------- /tests/tests/test_fields.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from gdpr.fields import Fields 4 | from germanium.tools import assert_list_equal, assert_true 5 | from tests.anonymizers import CustomerAnonymizer 6 | from tests.models import Customer 7 | 8 | 9 | LOCAL_FIELDS = ("first_name", "last_name") 10 | 11 | BASIC_FIELDS = ( 12 | "primary_email_address", 13 | ("emails", ( 14 | "email", 15 | )), 16 | ) 17 | 18 | MULTILEVEL_FIELDS = ( 19 | ("accounts", ( 20 | "number", 21 | "owner", 22 | ("payments", ( 23 | "value", 24 | "date" 25 | )) 26 | )), 27 | ) 28 | 29 | 30 | class TestFields(TestCase): 31 | def test_local_all(self): 32 | fields = Fields('__ALL__', Customer) 33 | assert_list_equal(fields.local_fields, list(CustomerAnonymizer().fields.keys())) 34 | 35 | def test_local(self): 36 | fields = Fields(LOCAL_FIELDS, Customer) 37 | assert_list_equal(fields.local_fields, list(LOCAL_FIELDS)) 38 | 39 | def test_local_and_related(self): 40 | fields = Fields(BASIC_FIELDS, Customer) 41 | 42 | assert_list_equal(fields.local_fields, ['primary_email_address']) 43 | assert_true('emails' in fields.related_fields) 44 | assert_list_equal(fields.related_fields['emails'].local_fields, ['email']) 45 | 46 | def test_multilevel_related(self): 47 | fields = Fields(MULTILEVEL_FIELDS, Customer) 48 | 49 | assert_list_equal(fields.local_fields, []) 50 | assert_true('accounts' in fields.related_fields) 51 | assert_list_equal(fields.related_fields['accounts'].local_fields, ['number', 'owner']) 52 | assert_true('payments' in fields.related_fields['accounts'].related_fields) 53 | assert_list_equal(fields.related_fields['accounts'].related_fields['payments'].local_fields, ['value', 'date']) 54 | -------------------------------------------------------------------------------- /tests/tests/test_legal_reason.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.models import ContentType 2 | from django.test import TestCase 3 | 4 | from faker import Faker 5 | from gdpr.models import LegalReason 6 | from germanium.tools import assert_equal, assert_not_equal, assert_true, assert_raises 7 | from tests.models import Account, Customer, Email, Payment 8 | from tests.purposes import ( 9 | ACCOUNT_AND_PAYMENT_SLUG, ACCOUNT_SLUG, EMAIL_SLUG, EVERYTHING_SLUG, FACEBOOK_SLUG, FIRST_AND_LAST_NAME_SLUG, 10 | EmailsPurpose, FacebookPurpose, MarketingPurpose 11 | ) 12 | from tests.tests.data import ( 13 | ACCOUNT__NUMBER, ACCOUNT__NUMBER2, ACCOUNT__OWNER, ACCOUNT__OWNER2, CUSTOMER__BIRTH_DATE, CUSTOMER__EMAIL, 14 | CUSTOMER__EMAIL2, CUSTOMER__EMAIL3, CUSTOMER__FACEBOOK_ID, CUSTOMER__FIRST_NAME, CUSTOMER__IP, CUSTOMER__KWARGS, 15 | CUSTOMER__LAST_NAME, CUSTOMER__PERSONAL_ID, CUSTOMER__PHONE_NUMBER 16 | ) 17 | from tests.tests.utils import AnonymizedDataMixin, NotImplementedMixin 18 | 19 | 20 | class TestLegalReason(AnonymizedDataMixin, NotImplementedMixin, TestCase): 21 | 22 | def setUp(self): 23 | self.fake = Faker() 24 | 25 | @classmethod 26 | def setUpTestData(cls): 27 | cls.customer: Customer = Customer(**CUSTOMER__KWARGS) 28 | cls.customer.save() 29 | 30 | def test_create_legal_reson_from_slug(self): 31 | LegalReason.objects.create_consent(FIRST_AND_LAST_NAME_SLUG, self.customer).save() 32 | 33 | assert_true(LegalReason.objects.filter( 34 | purpose_slug=FIRST_AND_LAST_NAME_SLUG, source_object_id=self.customer.pk, 35 | source_object_content_type=ContentType.objects.get_for_model(Customer)).exists()) 36 | 37 | def test_expirement_legal_reason(self): 38 | legal = LegalReason.objects.create_consent(FIRST_AND_LAST_NAME_SLUG, self.customer) 39 | legal.expire() 40 | 41 | anon_customer = Customer.objects.get(pk=self.customer.pk) 42 | 43 | assert_not_equal(anon_customer.first_name, CUSTOMER__FIRST_NAME) 44 | self.assertAnonymizedDataExists(anon_customer, "first_name") 45 | assert_not_equal(anon_customer.last_name, CUSTOMER__LAST_NAME) 46 | self.assertAnonymizedDataExists(anon_customer, "last_name") 47 | # make sure only data we want were anonymized 48 | assert_equal(anon_customer.primary_email_address, CUSTOMER__EMAIL) 49 | self.assertAnonymizedDataNotExists(anon_customer, "primary_email_address") 50 | 51 | def test_renew_legal_reason(self): 52 | legal = LegalReason.objects.create_consent(FIRST_AND_LAST_NAME_SLUG, self.customer) 53 | legal.expire() 54 | legal.renew() 55 | 56 | anon_customer = Customer.objects.get(pk=self.customer.pk) 57 | 58 | # Non reversible anonymization 59 | assert_not_equal(anon_customer.first_name, CUSTOMER__FIRST_NAME) 60 | self.assertAnonymizedDataExists(anon_customer, "first_name") 61 | assert_not_equal(anon_customer.last_name, CUSTOMER__LAST_NAME) 62 | self.assertAnonymizedDataExists(anon_customer, "last_name") 63 | 64 | def test_expirement_legal_reason_related(self): 65 | related_email: Email = Email(customer=self.customer, email=CUSTOMER__EMAIL) 66 | related_email.save() 67 | 68 | related_email2: Email = Email(customer=self.customer, email=CUSTOMER__EMAIL2) 69 | related_email2.save() 70 | 71 | related_email3: Email = Email(customer=self.customer, email=CUSTOMER__EMAIL3) 72 | related_email3.save() 73 | 74 | legal = LegalReason.objects.create_consent(EMAIL_SLUG, self.customer) 75 | legal.expire() 76 | 77 | anon_customer = Customer.objects.get(pk=self.customer.pk) 78 | 79 | assert_equal(anon_customer.primary_email_address, CUSTOMER__EMAIL) 80 | self.assertAnonymizedDataNotExists(anon_customer, "primary_email_address") 81 | 82 | # make sure only data we want were anonymized 83 | assert_equal(anon_customer.first_name, CUSTOMER__FIRST_NAME) 84 | self.assertAnonymizedDataNotExists(anon_customer, "first_name") 85 | 86 | anon_related_email: Email = Email.objects.get(pk=related_email.pk) 87 | 88 | assert_not_equal(anon_related_email.email, CUSTOMER__EMAIL) 89 | self.assertAnonymizedDataExists(anon_related_email, "email") 90 | 91 | anon_related_email2: Email = Email.objects.get(pk=related_email2.pk) 92 | 93 | assert_not_equal(anon_related_email2.email, CUSTOMER__EMAIL2) 94 | self.assertAnonymizedDataExists(anon_related_email2, "email") 95 | 96 | anon_related_email3: Email = Email.objects.get(pk=related_email3.pk) 97 | 98 | assert_not_equal(anon_related_email3.email, CUSTOMER__EMAIL3) 99 | self.assertAnonymizedDataExists(anon_related_email3, "email") 100 | 101 | def test_renew_legal_reason_related(self): 102 | related_email: Email = Email(customer=self.customer, email=CUSTOMER__EMAIL) 103 | related_email.save() 104 | 105 | related_email2: Email = Email(customer=self.customer, email=CUSTOMER__EMAIL2) 106 | related_email2.save() 107 | 108 | related_email3: Email = Email(customer=self.customer, email=CUSTOMER__EMAIL3) 109 | related_email3.save() 110 | 111 | legal = LegalReason.objects.create_consent(EMAIL_SLUG, self.customer) 112 | legal.expire() 113 | 114 | anon_legal = LegalReason.objects.get(pk=legal.pk) 115 | anon_legal.renew() 116 | 117 | anon_customer = Customer.objects.get(pk=self.customer.pk) 118 | 119 | assert_equal(anon_customer.primary_email_address, CUSTOMER__EMAIL) 120 | self.assertAnonymizedDataNotExists(anon_customer, "primary_email_address") 121 | 122 | # make sure only data we want were anonymized 123 | assert_equal(anon_customer.first_name, CUSTOMER__FIRST_NAME) 124 | self.assertAnonymizedDataNotExists(anon_customer, "first_name") 125 | 126 | anon_related_email: Email = Email.objects.get(pk=related_email.pk) 127 | 128 | assert_equal(anon_related_email.email, CUSTOMER__EMAIL) 129 | self.assertAnonymizedDataNotExists(anon_related_email, "email") 130 | 131 | anon_related_email2: Email = Email.objects.get(pk=related_email2.pk) 132 | 133 | assert_equal(anon_related_email2.email, CUSTOMER__EMAIL2) 134 | self.assertAnonymizedDataNotExists(anon_related_email2, "email") 135 | 136 | anon_related_email3: Email = Email.objects.get(pk=related_email3.pk) 137 | 138 | assert_equal(anon_related_email3.email, CUSTOMER__EMAIL3) 139 | self.assertAnonymizedDataNotExists(anon_related_email3, "email") 140 | 141 | def test_expirement_legal_reason_two_level_related(self): 142 | account_1: Account = Account(customer=self.customer, number=ACCOUNT__NUMBER, owner=ACCOUNT__OWNER) 143 | account_1.save() 144 | account_2: Account = Account(customer=self.customer, number=ACCOUNT__NUMBER2, owner=ACCOUNT__OWNER2) 145 | account_2.save() 146 | 147 | payment_1: Payment = Payment(account=account_1, 148 | value=self.fake.pydecimal(left_digits=8, right_digits=2, positive=True)) 149 | payment_1.save() 150 | payment_2: Payment = Payment(account=account_1, 151 | value=self.fake.pydecimal(left_digits=8, right_digits=2, positive=True)) 152 | payment_2.save() 153 | 154 | payment_3: Payment = Payment(account=account_2, 155 | value=self.fake.pydecimal(left_digits=8, right_digits=2, positive=True)) 156 | payment_3.save() 157 | payment_4: Payment = Payment(account=account_2, 158 | value=self.fake.pydecimal(left_digits=8, right_digits=2, positive=True)) 159 | payment_4.save() 160 | 161 | legal = LegalReason.objects.create_consent(ACCOUNT_AND_PAYMENT_SLUG, self.customer) 162 | legal.expire() 163 | 164 | anon_account_1: Account = Account.objects.get(pk=account_1.pk) 165 | 166 | assert_not_equal(anon_account_1.number, ACCOUNT__NUMBER) 167 | self.assertAnonymizedDataExists(anon_account_1, "number") 168 | assert_not_equal(anon_account_1.owner, ACCOUNT__OWNER) 169 | self.assertAnonymizedDataExists(anon_account_1, "owner") 170 | 171 | anon_account_2: Account = Account.objects.get(pk=account_2.pk) 172 | 173 | assert_not_equal(anon_account_2.number, ACCOUNT__NUMBER2) 174 | self.assertAnonymizedDataExists(anon_account_2, "number") 175 | assert_not_equal(anon_account_2.owner, ACCOUNT__OWNER2) 176 | self.assertAnonymizedDataExists(anon_account_2, "owner") 177 | 178 | for payment in [payment_1, payment_2, payment_3, payment_4]: 179 | anon_payment: Payment = Payment.objects.get(pk=payment.pk) 180 | 181 | assert_not_equal(anon_payment.value, payment.value) 182 | self.assertAnonymizedDataExists(anon_payment, "value") 183 | assert_not_equal(anon_payment.date, payment.date) 184 | self.assertAnonymizedDataExists(anon_payment, "date") 185 | 186 | def test_renew_legal_reason_two_level_related(self): 187 | account_1: Account = Account(customer=self.customer, number=ACCOUNT__NUMBER, owner=ACCOUNT__OWNER) 188 | account_1.save() 189 | account_2: Account = Account(customer=self.customer, number=ACCOUNT__NUMBER2, owner=ACCOUNT__OWNER2) 190 | account_2.save() 191 | 192 | payment_1: Payment = Payment(account=account_1, 193 | value=self.fake.pydecimal(left_digits=8, right_digits=2, positive=True)) 194 | payment_1.save() 195 | payment_2: Payment = Payment(account=account_1, 196 | value=self.fake.pydecimal(left_digits=8, right_digits=2, positive=True)) 197 | payment_2.save() 198 | 199 | payment_3: Payment = Payment(account=account_2, 200 | value=self.fake.pydecimal(left_digits=8, right_digits=2, positive=True)) 201 | payment_3.save() 202 | payment_4: Payment = Payment(account=account_2, 203 | value=self.fake.pydecimal(left_digits=8, right_digits=2, positive=True)) 204 | payment_4.save() 205 | 206 | legal = LegalReason.objects.create_consent(ACCOUNT_AND_PAYMENT_SLUG, self.customer) 207 | legal.expire() 208 | 209 | anon_legal = LegalReason.objects.get(pk=legal.pk) 210 | anon_legal.renew() 211 | 212 | anon_account_1: Account = Account.objects.get(pk=account_1.pk) 213 | 214 | assert_equal(anon_account_1.number, ACCOUNT__NUMBER) 215 | self.assertAnonymizedDataNotExists(anon_account_1, "number") 216 | assert_equal(anon_account_1.owner, ACCOUNT__OWNER) 217 | self.assertAnonymizedDataNotExists(anon_account_1, "owner") 218 | 219 | anon_account_2: Account = Account.objects.get(pk=account_2.pk) 220 | 221 | assert_equal(anon_account_2.number, ACCOUNT__NUMBER2) 222 | self.assertAnonymizedDataNotExists(anon_account_2, "number") 223 | assert_equal(anon_account_2.owner, ACCOUNT__OWNER2) 224 | self.assertAnonymizedDataNotExists(anon_account_2, "owner") 225 | 226 | for payment in [payment_1, payment_2, payment_3, payment_4]: 227 | anon_payment: Payment = Payment.objects.get(pk=payment.pk) 228 | 229 | assert_equal(anon_payment.value, payment.value) 230 | self.assertAnonymizedDataNotExists(anon_payment, "value") 231 | assert_equal(anon_payment.date, payment.date) 232 | self.assertAnonymizedDataNotExists(anon_payment, "date") 233 | 234 | def test_email_purpose(self): 235 | LegalReason.objects.create_consent(EMAIL_SLUG, self.customer) 236 | 237 | EmailsPurpose().anonymize_obj(obj=self.customer, fields=("primary_email_address",)) 238 | 239 | anon_customer = Customer.objects.get(pk=self.customer.pk) 240 | 241 | assert_equal(anon_customer.primary_email_address, CUSTOMER__EMAIL) 242 | self.assertAnonymizedDataNotExists(self.customer, 'primary_email_address') 243 | 244 | def test_email_purpose_related(self): 245 | LegalReason.objects.create_consent(EMAIL_SLUG, self.customer) 246 | 247 | related_email: Email = Email(customer=self.customer, email=CUSTOMER__EMAIL) 248 | related_email.save() 249 | 250 | EmailsPurpose().anonymize_obj(obj=self.customer, fields=("primary_email_address",)) 251 | 252 | anon_customer = Customer.objects.get(pk=self.customer.pk) 253 | 254 | assert_equal(anon_customer.primary_email_address, CUSTOMER__EMAIL) 255 | self.assertAnonymizedDataNotExists(anon_customer, 'primary_email_address') 256 | 257 | anon_related_email: Email = Email.objects.get(pk=related_email.pk) 258 | 259 | assert_equal(anon_related_email.email, CUSTOMER__EMAIL) 260 | self.assertAnonymizedDataNotExists(anon_related_email, 'email') 261 | 262 | def test_legal_reason_hardcore(self): 263 | related_email: Email = Email(customer=self.customer, email=CUSTOMER__EMAIL) 264 | related_email.save() 265 | 266 | related_email2: Email = Email(customer=self.customer, email=CUSTOMER__EMAIL2) 267 | related_email2.save() 268 | 269 | account: Account = Account(customer=self.customer, number=ACCOUNT__NUMBER, owner=ACCOUNT__OWNER) 270 | account.save() 271 | 272 | payment: Payment = Payment(account=account, 273 | value=self.fake.pydecimal(left_digits=8, right_digits=2, positive=True)) 274 | payment.save() 275 | 276 | LegalReason.objects.create_consent(FIRST_AND_LAST_NAME_SLUG, self.customer) 277 | LegalReason.objects.create_consent(EMAIL_SLUG, self.customer) 278 | LegalReason.objects.create_consent(ACCOUNT_SLUG, self.customer) 279 | legal = LegalReason.objects.create_consent(EVERYTHING_SLUG, self.customer) 280 | legal.expire() 281 | 282 | anon_customer: Customer = Customer.objects.get(pk=self.customer.pk) 283 | anon_related_email: Email = Email.objects.get(pk=related_email.pk) 284 | anon_related_email2: Email = Email.objects.get(pk=related_email2.pk) 285 | anon_account: Account = Account.objects.get(pk=account.pk) 286 | anon_payment: Payment = Payment.objects.get(pk=payment.pk) 287 | 288 | # Customer - partialy anonymized 289 | assert_equal(anon_customer.first_name, CUSTOMER__FIRST_NAME) 290 | self.assertAnonymizedDataNotExists(anon_customer, 'first_name') 291 | assert_equal(anon_customer.last_name, CUSTOMER__LAST_NAME) 292 | self.assertAnonymizedDataNotExists(anon_customer, 'last_name') 293 | assert_not_equal(anon_customer.primary_email_address, CUSTOMER__EMAIL) 294 | self.assertAnonymizedDataExists(anon_customer, 'primary_email_address') 295 | 296 | assert_not_equal(anon_customer.birth_date, CUSTOMER__BIRTH_DATE) 297 | self.assertAnonymizedDataExists(anon_customer, 'birth_date') 298 | assert_not_equal(anon_customer.personal_id, CUSTOMER__PERSONAL_ID) 299 | self.assertAnonymizedDataExists(anon_customer, 'personal_id') 300 | assert_not_equal(anon_customer.phone_number, CUSTOMER__PHONE_NUMBER) 301 | self.assertAnonymizedDataExists(anon_customer, 'phone_number') 302 | assert_not_equal(anon_customer.facebook_id, CUSTOMER__FACEBOOK_ID) 303 | self.assertAnonymizedDataExists(anon_customer, 'facebook_id') 304 | assert_not_equal(anon_customer.last_login_ip, CUSTOMER__IP) 305 | self.assertAnonymizedDataExists(anon_customer, 'last_login_ip') 306 | 307 | # Email - not anonymized 308 | assert_equal(anon_related_email.email, CUSTOMER__EMAIL) 309 | self.assertAnonymizedDataNotExists(anon_related_email, 'email') 310 | assert_equal(anon_related_email2.email, CUSTOMER__EMAIL2) 311 | self.assertAnonymizedDataNotExists(anon_related_email2, 'email') 312 | 313 | # Account - not anonymized 314 | assert_equal(anon_account.number, ACCOUNT__NUMBER) 315 | self.assertAnonymizedDataNotExists(anon_account, "number") 316 | assert_equal(anon_account.owner, ACCOUNT__OWNER) 317 | self.assertAnonymizedDataNotExists(anon_account, "owner") 318 | 319 | # Payment - fully anonymized 320 | assert_not_equal(anon_payment.value, payment.value) 321 | self.assertAnonymizedDataExists(anon_payment, "value") 322 | assert_not_equal(anon_payment.date, payment.date) 323 | self.assertAnonymizedDataExists(anon_payment, "date") 324 | 325 | def test_purpose_source_model_class_should_be_set_with_string(self): 326 | assert_equal(FacebookPurpose.source_model_class, Customer) 327 | 328 | def test_purpose_source_model_class_should_be_set_directly(self): 329 | assert_equal(MarketingPurpose.source_model_class, Customer) 330 | 331 | def test_purpose_should_be_set_only_to_the_right_source_model(self): 332 | LegalReason.objects.create_consent(FACEBOOK_SLUG, self.customer) 333 | with assert_raises(KeyError): 334 | LegalReason.objects.create_consent(FACEBOOK_SLUG, Email(customer=self.customer, email=CUSTOMER__EMAIL)) 335 | 336 | def test_facebook_purpose_should_anonymize_customer_with_facebook_id(self): 337 | customer = Customer.objects.get(pk=self.customer.pk) 338 | legal_reason = LegalReason.objects.create_consent(FACEBOOK_SLUG, customer) 339 | legal_reason.save() 340 | legal_reason.expire() 341 | customer.refresh_from_db() 342 | assert_not_equal(customer.first_name, CUSTOMER__FIRST_NAME) 343 | 344 | def test_facebook_purpose_should_anonymize_customer_without_facebook_id(self): 345 | customer = Customer.objects.get(pk=self.customer.pk) 346 | legal_reason = LegalReason.objects.create_consent(FACEBOOK_SLUG, customer) 347 | customer.facebook_id = None 348 | customer.save() 349 | legal_reason.save() 350 | legal_reason.expire() 351 | customer.refresh_from_db() 352 | assert_equal(customer.first_name, CUSTOMER__FIRST_NAME) 353 | -------------------------------------------------------------------------------- /tests/tests/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | from django.contrib.contenttypes.models import ContentType 4 | from django.db.models import Model 5 | from django.test import TestCase 6 | 7 | from gdpr.models import AnonymizedData 8 | from germanium.tools import assert_false, assert_true 9 | 10 | 11 | class NotImplementedMixin(TestCase): 12 | def assertNotImplemented(self, func: Callable, *args, **kwargs) -> None: 13 | try: 14 | func(*args, **kwargs) 15 | except AssertionError as exc: 16 | print("NOT IMPLEMENTED:", self.id(), exc) 17 | else: 18 | raise AssertionError("Function Implemented successfully!!") 19 | 20 | def assertNotImplementedNotEqual(self, *args, **kwargs): 21 | self.assertNotImplemented(self.assertNotEqual, *args, **kwargs) 22 | 23 | 24 | class AnonymizedDataMixin(TestCase): 25 | def assertAnonymizedDataExists(self, obj: Model, field: str): 26 | content_type = ContentType.objects.get_for_model(obj.__class__) 27 | assert_true( 28 | AnonymizedData.objects.filter(content_type=content_type, object_id=str(obj.pk), field=field).exists()) 29 | 30 | def assertAnonymizedDataNotExists(self, obj: Model, field: str): 31 | content_type = ContentType.objects.get_for_model(obj.__class__) 32 | assert_false( 33 | AnonymizedData.objects.filter(content_type=content_type, object_id=str(obj.pk), field=field).exists()) 34 | -------------------------------------------------------------------------------- /tests/validators.py: -------------------------------------------------------------------------------- 1 | import re 2 | from datetime import date 3 | 4 | from django.core.exceptions import ValidationError 5 | from django.utils.encoding import force_text 6 | from django.utils.translation import ugettext_lazy as _ 7 | 8 | 9 | def get_day_from_personal_id(personal_id): 10 | day = int(personal_id[4:6]) 11 | if day > 50: 12 | day -= 50 13 | return day 14 | 15 | 16 | def get_month_from_personal_id(personal_id): 17 | year = get_year_from_personal_id(personal_id) 18 | month = int(personal_id[2:4]) 19 | if month > 70 and year > 2003: 20 | month -= 70 21 | elif month > 50: 22 | month -= 50 23 | elif month > 20 and year > 2003: 24 | month -= 20 25 | return month 26 | 27 | 28 | def get_year_from_personal_id(personal_id): 29 | year = int(personal_id[0:2]) 30 | value = personal_id.replace('/', '') 31 | year += 2000 if year < 54 and len(value) == 10 else 1900 32 | return year 33 | 34 | 35 | def personal_id_date(personal_id): 36 | try: 37 | return date(get_year_from_personal_id(personal_id), get_month_from_personal_id(personal_id), 38 | get_day_from_personal_id(personal_id)) 39 | except ValueError: 40 | raise ValueError('Invalid personal id') 41 | 42 | 43 | class CZBirthNumberValidator: 44 | """ 45 | Czech birth number field validator. 46 | """ 47 | BIRTH_NUMBER = re.compile(r'^(?P\d{6})/?(?P\d{3,4})$') 48 | 49 | def __call__(self, value): 50 | value = force_text(value) 51 | 52 | match = re.match(self.BIRTH_NUMBER, value) 53 | if not match: 54 | raise ValidationError(_('Enter a birth number in the format XXXXXX/XXXX.')) 55 | 56 | birth, id = match.groupdict()['birth'], match.groupdict()['id'] 57 | 58 | # Three digits for verificatin number were used until 1. january 1954 59 | if len(id) != 3: 60 | # Fourth digit has been added since 1. January 1954. 61 | # It is modulo of dividing birth number and verification number by 11. 62 | # If the modulo were 10, the last number was 0 (and therefore, the whole 63 | # birth number weren't dividable by 11. These number are no longer used (since 1985) 64 | # and condition 'modulo == 10' can be removed in 2085. 65 | 66 | modulo = int(birth + id[:3]) % 11 67 | 68 | if (modulo != int(id[-1])) and (modulo != 10 or id[-1] != '0'): 69 | raise ValidationError(_('Enter a valid birth number.')) 70 | 71 | try: 72 | personal_id_date(value) 73 | except ValueError: 74 | raise ValidationError(_('Enter a valid birth number.')) 75 | 76 | 77 | class IDCardNoValidator: 78 | """ 79 | Czech id card number field validator. 80 | """ 81 | ID_CARD_NUMBER = re.compile(r'^\d{9}$') 82 | 83 | def __call__(self, value): 84 | value = force_text(value) 85 | 86 | match = re.match(self.ID_CARD_NUMBER, value) 87 | if not match: 88 | raise ValidationError(_('Enter an ID card in the format XXXXXXXXX.')) 89 | elif value[0] == '0': 90 | raise ValidationError(_('Enter a valid ID card number.')) 91 | else: 92 | return value 93 | 94 | 95 | class BankAccountValidator: 96 | BANK_ACCOUNT_NUMBER_REVERSE_PATTERN = re.compile( 97 | r'^(?P\d{1,6})/(?P\d{1,10})(-?(?P\d{1,6}))?$') 98 | 99 | def __call__(self, value): 100 | match = re.match(self.BANK_ACCOUNT_NUMBER_REVERSE_PATTERN, force_text(value)[::-1]) 101 | if match: 102 | return construct_bank_account_number((match.groupdict()['prefix'] or '')[::-1], 103 | match.groupdict()['number'][::-1], 104 | match.groupdict()['bank'][::-1]) 105 | else: 106 | raise ValidationError(_('Enter a valid bank account number.')) 107 | 108 | 109 | def construct_bank_account_number(prefix, number, bank_code): 110 | return '{:0>6}-{:0>10}/{}'.format(prefix, number, bank_code) 111 | 112 | 113 | def split_bank_account_to_prefix_postfix(bank_account_number): 114 | return bank_account_number.split('-') if '-' in bank_account_number else ('', bank_account_number) 115 | 116 | 117 | def clean_bank_account_number_or_none(bank_account_number): 118 | try: 119 | return BankAccountValidator()(bank_account_number) 120 | except ValidationError: 121 | return None 122 | --------------------------------------------------------------------------------