├── .gitignore ├── AUTHORS ├── CHANGELOG.rst ├── LICENSE ├── MANIFEST.in ├── README.md ├── hattori ├── __init__.py ├── base.py ├── constants.py ├── exceptions.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── anonymize_db.py └── utils.py ├── setup.py ├── tests ├── __init__.py ├── anonymizers.py ├── conftest.py ├── manage.py ├── models.py ├── requirements.txt ├── settings.py └── test_command.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | 46 | # Translations 47 | *.mo 48 | *.pot 49 | 50 | # Django stuff: 51 | *.log 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | docs/_static 56 | docs/_templates 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | # PyCharm 62 | .idea 63 | 64 | # Python decouple settings file 65 | settings.ini 66 | 67 | log/ 68 | py27/ 69 | py35/ 70 | flake8/ 71 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Marc Galofré 2 | Marc Hernández -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Change log 3 | ========== 4 | 5 | 0.1.0 (2018-05-10) 6 | ------------------ 7 | 8 | * Initial release. 9 | 10 | 11 | 0.1.1 (2018-06-11) 12 | ------------------ 13 | 14 | * ModuleNotFoundError is a new exception in Python 3.6, whereas earlier versions use ImportError. 15 | 16 | 17 | 0.1.2 (2018-07-27) 18 | ------------------ 19 | 20 | * Add progress bar that indicates the progress of the anonymization operation. 21 | 22 | 23 | 0.2.0 (2018-10-28) 24 | ------------------ 25 | 26 | * Added tests 27 | * Small code refactors 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 - APSL 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS 2 | include LICENSE 3 | include CHANGELOG.rst 4 | include README.md 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django-Hattori 2 | 3 | [![pypi](https://img.shields.io/pypi/v/django-hattori.svg)](https://pypi.python.org/pypi/django-hattori/) 4 | 5 | 6 | Command to anonymize sensitive data. This app helps you anonymize data in a database used for development of a Django project. 7 | 8 | This app is based on [Django-Database-Anonymizer](https://github.com/Blueshoe/Django-Database-Anonymizer), using [Faker](https://github.com/joke2k/faker) to anonymize the values. 9 | 10 | ## Installation 11 | 12 | Install using pip: 13 | 14 | ``` 15 | pip install django-hattori 16 | ``` 17 | 18 | Then add ``'hattori'`` to your ``INSTALLED_APPS``. 19 | 20 | ``` 21 | INSTALLED_APPS = [ 22 | ... 23 | 'hattori', 24 | ] 25 | ``` 26 | 27 | ### Important 28 | 29 | ***You should only run the anonymize process in PRE or development environments***. To avoid problems by default anonymization is disabled. 30 | 31 | To enable you must add to settings ```ANONYMIZE_ENABLED=True``` 32 | 33 | 34 | ## Usage 35 | 36 | How to execute command: 37 | 38 | ./manage.py anonymize_db 39 | 40 | Possible arguments: 41 | 42 | * ```-a, --app```: Define a app you want to anonymize. All anonymizers in this app will be run. Eg. ```anonymize_db -a shop``` 43 | * ```-m, --models```: List of models you want to anonymize. Eg. ```anonymize_db -m Customer,Product``` 44 | * ```-b, --batch-size```: batch size used in the bulk_update of the instances. Depends on the DB machine, default use 500. 45 | 46 | 47 | ## Writing anonymizers 48 | 49 | In order to use the management command we need to define _**anonymizers**_. 50 | 51 | * Create a module _anonymizers.py_ in the given django-app 52 | * An _anonymizer_ is a simple class that inherits from ```BaseAnonymizer``` 53 | * Each anonymizer class is going to represent **one** model 54 | * An anonymizer has the following members: 55 | * ```model```: (required) The model class for this anonymizer 56 | * ```attributes```: (required) List of tuples that determine which fields to replace. The first value of the tuple is the fieldname, the second value is the _**replacer**_ 57 | * ```get_query_set()```: (optional) Define your QuerySet 58 | * A _replacer_ is either of type _str_ or _callable_ 59 | * A callable _replacer_ is a Faker instance or custom replacer. 60 | * All Faker methods are available. For more info read the official documentation [Faker!](http://faker.readthedocs.io/en/master/providers.html) 61 | 62 | 63 | #### Example 64 | ``` 65 | from hattori.base import BaseAnonymizer, faker 66 | from shop.models import Customer 67 | 68 | class CustomerAnonymizer(BaseAnonymizer): 69 | model = Customer 70 | 71 | attributes = [ 72 | ('card_number', faker.credit_card_number), 73 | ('first_name', faker.first_name), 74 | ('last_name', faker.last_name), 75 | ('phone', faker.phone_number), 76 | ('email', faker.email), 77 | ('city', faker.city), 78 | ('comment', faker.text), 79 | ('description', 'fix string'), 80 | ('code', faker.pystr), 81 | ] 82 | 83 | def get_query_set(self): 84 | return Customer.objects.filter(age__gt=18) 85 | ``` 86 | 87 | #### Extending the existing replacers with arguments 88 | Use lambdas to extend certain predefined replacers with arguments, like `min_chars` or `max_chars` on `faker.pystr`: 89 | 90 | ``` 91 | ('code', lambda **kwargs: faker.pystr(min_chars=250, max_chars=250, **kwargs)), 92 | ``` 93 | 94 | **Important**: don't forget the ****kwargs**! 95 | -------------------------------------------------------------------------------- /hattori/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | __author__ = 'Marc Galofré' 3 | __email__ = 'mgalofre@apsl.net' 4 | __version__ = '0.2.1' 5 | -------------------------------------------------------------------------------- /hattori/base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | 5 | from tqdm import tqdm 6 | from six import string_types 7 | from django.conf import settings 8 | from bulk_update.helper import bulk_update 9 | from faker import Faker 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | try: 14 | faker = Faker(settings.LANGUAGE_CODE) 15 | except AttributeError: 16 | faker = Faker() 17 | 18 | 19 | class BaseAnonymizer: 20 | model = None 21 | attributes = None 22 | 23 | def __init__(self): 24 | if not self.model or not self.attributes: 25 | logger.info('ERROR: Your anonymizer is missing the model or attributes definition!') 26 | exit(1) 27 | 28 | def get_query_set(self): 29 | """ 30 | You can override this in your Anonymizer. 31 | :return: QuerySet 32 | """ 33 | return self.model.objects.all() 34 | 35 | def run(self, batch_size): 36 | instances = self.get_query_set() 37 | instances_processed, count_instances, count_fields = self._process_instances(instances) 38 | bulk_update(instances_processed, update_fields=[attrs[0] for attrs in self.attributes], batch_size=batch_size) 39 | return len(self.attributes), count_instances, count_fields 40 | 41 | def _process_instances(self, instances): 42 | count_fields = 0 43 | count_instances = 0 44 | 45 | progress_bar = tqdm(desc="Processing", total=instances.count()) 46 | for model_instance in instances: 47 | for field_name, replacer in self.attributes: 48 | if callable(replacer): 49 | replaced_value = self.get_allowed_value(replacer, model_instance, field_name) 50 | elif isinstance(replacer, string_types): 51 | replaced_value = replacer 52 | else: 53 | raise TypeError('Replacers need to be callables or Strings!') 54 | setattr(model_instance, field_name, replaced_value) 55 | count_fields += 1 56 | count_instances += 1 57 | progress_bar.update(1) 58 | progress_bar.close() 59 | return instances, count_instances, count_fields 60 | 61 | @staticmethod 62 | def get_allowed_value(replacer, model_instance, field_name): 63 | retval = replacer() 64 | max_length = model_instance._meta.get_field(field_name).max_length 65 | if max_length: 66 | retval = retval[:max_length] 67 | return retval 68 | -------------------------------------------------------------------------------- /hattori/constants.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | ANONYMIZER_MODULE_NAME = 'anonymizers' 3 | DEFAULT_CHUNK_SIZE = 500 4 | -------------------------------------------------------------------------------- /hattori/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | class HattoriException(Exception): 3 | """ 4 | An exception class for Hattori errors 5 | """ 6 | def __init__(self, msg, original_exception=''): 7 | super().__init__(msg + (": %s" % original_exception)) 8 | self.original_exception = original_exception 9 | -------------------------------------------------------------------------------- /hattori/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/APSL/django-hattori/9ebbd45adabd01496d8fbaf938b402c8853b1200/hattori/management/__init__.py -------------------------------------------------------------------------------- /hattori/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/APSL/django-hattori/9ebbd45adabd01496d8fbaf938b402c8853b1200/hattori/management/commands/__init__.py -------------------------------------------------------------------------------- /hattori/management/commands/anonymize_db.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.core.management import BaseCommand 4 | 5 | from hattori import constants 6 | from hattori.exceptions import HattoriException 7 | from hattori.utils import setting, autodiscover_module, get_app_anonymizers 8 | 9 | 10 | class Command(BaseCommand): 11 | help = 'This command anonymize real (user-)data of model instances in your database with mock data.' 12 | modules = None # List of anonymizers modules. They can be placed in every app 13 | anonymize_enabled = setting('ANONYMIZE_ENABLED', False) 14 | 15 | def add_arguments(self, parser): 16 | parser.add_argument( 17 | '-a', 18 | '--app', 19 | help='Only anonymize the given app', 20 | dest="app", 21 | metavar="APP" 22 | ) 23 | parser.add_argument( 24 | "-m", 25 | "--model", 26 | "--models", 27 | dest="models", 28 | help="Models to anonymize. Separate multiples by comma.", 29 | metavar="MODEL" 30 | ) 31 | parser.add_argument( 32 | "-b", 33 | "--batch-size", 34 | dest="batch_size", 35 | help="batch size used in the bulk_update of the instances. Depends on the DB machine, defaults use 500.", 36 | metavar="BATCH_SIZE", 37 | type=int, 38 | default=constants.DEFAULT_CHUNK_SIZE, 39 | ) 40 | 41 | def handle(self, *args, **options): 42 | if not self.anonymize_enabled: 43 | self.stdout.write('You must set ANONYMIZE_ENABLED to True') 44 | exit() 45 | 46 | try: 47 | modules = autodiscover_module(constants.ANONYMIZER_MODULE_NAME, app_name=options.get('app')) 48 | except HattoriException as e: 49 | self.stdout.write(str(e)) 50 | exit(1) 51 | 52 | total_replacements_count = 0 53 | 54 | for module in modules: 55 | anonymizers = get_app_anonymizers(module, selected_models=options.get('models')) 56 | if len(anonymizers) == 0: 57 | continue 58 | 59 | for anonymizer_class_name in anonymizers: 60 | anonymizer = getattr(module, anonymizer_class_name)() 61 | # Start the anonymizing process 62 | self.stdout.write('{}.{}:'.format(module.__package__, anonymizer.model.__name__)) 63 | num_fields, num_instances, total = anonymizer.run(options.get('batch_size')) 64 | self.stdout.write( 65 | '- {} fields, {} model instances, {} total replacements'.format(num_fields, num_instances, total) 66 | ) 67 | total_replacements_count += total 68 | self.stdout.write('DONE. Replaced {} values in total'.format(total_replacements_count)) 69 | -------------------------------------------------------------------------------- /hattori/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import inspect 3 | import logging 4 | from importlib import import_module 5 | 6 | from django.conf import settings 7 | from django.core.exceptions import ImproperlyConfigured 8 | 9 | from hattori.base import BaseAnonymizer 10 | from hattori.exceptions import HattoriException 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | def setting(name, default=None, strict=False): 16 | """ 17 | Helper function to get a Django setting by name. If setting doesn't exists 18 | it can return a default or raise an error if in strict mode. 19 | 20 | :param name: Name of setting 21 | :type name: str 22 | :param default: Value if setting is unfound 23 | :param strict: Define if return default value or raise an error 24 | :type strict: bool 25 | :returns: Setting's value 26 | :raises: django.core.exceptions.ImproperlyConfigured if setting is unfound 27 | and strict mode 28 | """ 29 | if strict and not hasattr(settings, name): 30 | msg = "You must provide settings.%s" % name 31 | raise ImproperlyConfigured(msg) 32 | return getattr(settings, name, default) 33 | 34 | 35 | def autodiscover_module(module_name, app_name=None): 36 | logger.info('Autodiscovering anonymizers modules ...') 37 | apps_to_search = [app_name] if app_name else settings.INSTALLED_APPS 38 | modules = [] 39 | for app in apps_to_search: 40 | try: 41 | import_module(app) 42 | except ImportError as e: 43 | raise HattoriException('ERROR: Can not find app {}'.format(app), e) 44 | try: 45 | modules.append(import_module('%s.%s' % (app, module_name))) 46 | except ImportError as e: 47 | if app_name: 48 | raise HattoriException('ERROR: Can not find module {}'.format(module_name, app), e) 49 | logger.info('Found anonymizers for {} apps'.format(len(modules))) 50 | return modules 51 | 52 | 53 | def get_app_anonymizers(module, selected_models=None): 54 | logger.info('Autodiscovering Anonymizer classes from {} module...'.format(module.__package__)) 55 | models = None 56 | if selected_models is not None: 57 | models = [m.strip() for m in selected_models.split(',')] 58 | 59 | if models: 60 | clazzes = [m[0] for m in inspect.getmembers(module, inspect.isclass) 61 | if BaseAnonymizer in m[1].__bases__ and m[1].model.__name__ in models] 62 | else: 63 | clazzes = [m[0] for m in inspect.getmembers(module, inspect.isclass) if BaseAnonymizer in m[1].__bases__] 64 | if len(clazzes) == 0: 65 | logger.info('Not found any Anonymizer class from {} module'.format(module.__package__)) 66 | return clazzes 67 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | import os 4 | import re 5 | import codecs 6 | 7 | try: 8 | from setuptools import setup, find_packages 9 | except ImportError: 10 | from distutils.core import setup 11 | 12 | 13 | def get_version(package): 14 | """ 15 | Return package version as listed in `__version__` in `init.py`. 16 | """ 17 | init_py = codecs.open(os.path.abspath(os.path.join(package, '__init__.py')), encoding='utf-8').read() 18 | return re.search("^__version__ = ['\"]([^'\"]+)['\"]", init_py, re.MULTILINE).group(1) 19 | 20 | 21 | def get_author(package): 22 | """ 23 | Return package author as listed in `__author__` in `init.py`. 24 | """ 25 | init_py = codecs.open(os.path.abspath(os.path.join(package, '__init__.py')), encoding='utf-8').read() 26 | return re.search("^__author__ = ['\"]([^'\"]+)['\"]", init_py, re.MULTILINE).group(1) 27 | 28 | 29 | def get_email(package): 30 | """ 31 | Return package email as listed in `__email__` in `init.py`. 32 | """ 33 | init_py = codecs.open(os.path.abspath(os.path.join(package, '__init__.py')), encoding='utf-8').read() 34 | return re.search("^__email__ = ['\"]([^'\"]+)['\"]", init_py, re.MULTILINE).group(1) 35 | 36 | 37 | def get_long_description(): 38 | """ 39 | return the long description from README.md file 40 | :return: 41 | """ 42 | return codecs.open(os.path.join(os.path.dirname(__file__), 'README.md'), encoding='utf-8').read() 43 | 44 | 45 | setup( 46 | name='django-hattori', 47 | version=get_version('hattori'), 48 | author=get_author('hattori'), 49 | author_email=get_email('hattori'), 50 | url='https://github.com/APSL/django-hattori', 51 | packages=find_packages(exclude=['tests*']), 52 | description='Command to anonymize sensitive data.', 53 | long_description_content_type="text/markdown", 54 | long_description=get_long_description(), 55 | install_requires=[ 56 | 'Django>=1.8', 57 | 'django-bulk-update>=2.2.0', 58 | 'Faker>=0.8.13', 59 | 'six', 60 | 'tqdm>=4.23.4', 61 | ], 62 | classifiers=[ 63 | 'Environment :: Web Environment', 64 | 'Intended Audience :: Developers', 65 | 'Programming Language :: Python', 66 | 'Programming Language :: Python :: 3', 67 | 'Programming Language :: Python :: 3.5', 68 | 'Operating System :: OS Independent', 69 | 'Topic :: Software Development' 70 | ], 71 | include_package_data=True, 72 | zip_safe=False, 73 | ) 74 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/APSL/django-hattori/9ebbd45adabd01496d8fbaf938b402c8853b1200/tests/__init__.py -------------------------------------------------------------------------------- /tests/anonymizers.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from hattori.base import BaseAnonymizer, faker 4 | from tests.models import Person 5 | 6 | 7 | class PersonAnonymizer(BaseAnonymizer): 8 | model = Person 9 | attributes = [ 10 | # ('card_number', faker.credit_card_number), 11 | ('first_name', faker.first_name), 12 | ('last_name', faker.last_name), 13 | # ('phone', faker.phone_number), 14 | # ('email', faker.email), 15 | # ('city', faker.city), 16 | # ('comment', faker.text), 17 | # ('description', 'fix string'), 18 | # ('code', faker.pystr), 19 | ] 20 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | import os 4 | import django 5 | 6 | 7 | def pytest_configure(): 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 9 | django.setup() 10 | -------------------------------------------------------------------------------- /tests/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == '__main__': 6 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'settings') 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError as exc: 10 | raise ImportError( 11 | "Couldn't import Django. Are you sure it's installed and " 12 | "available on your PYTHONPATH environment variable? Did you " 13 | "forget to activate a virtual environment?" 14 | ) from exc 15 | execute_from_command_line(sys.argv) 16 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from django.db import models 4 | 5 | 6 | class Person(models.Model): 7 | 8 | first_name = models.CharField(max_length=100) 9 | last_name = models.CharField(max_length=100) 10 | 11 | class Meta: 12 | verbose_name = 'Person' 13 | verbose_name_plural = 'Persons' 14 | 15 | def __str__(self): 16 | return '{} {}'.format(self.first_name, self.last_name) 17 | -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-django 3 | model_mommy -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | DATABASES = { 4 | 'default': { 5 | 'ENGINE': 'django.db.backends.sqlite3', 6 | 'NAME': ':memory:', 7 | }, 8 | } 9 | 10 | INSTALLED_APPS = [ 11 | 'django.contrib.contenttypes', 12 | 'django.contrib.staticfiles', 13 | 'django.contrib.auth', 14 | 'hattori', 15 | 'tests' 16 | ] 17 | 18 | MIDDLEWARE = [] 19 | 20 | USE_TZ = True 21 | 22 | SECRET_KEY = 'foobar' 23 | 24 | LANGUAGE_CODE = 'en-us' 25 | 26 | TEMPLATES = [{ 27 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 28 | 'APP_DIRS': True, 29 | }] 30 | 31 | 32 | STATIC_URL = '/static/' 33 | 34 | ANONYMIZE_ENABLED = True 35 | -------------------------------------------------------------------------------- /tests/test_command.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | import pytest 4 | 5 | from model_mommy import mommy 6 | from model_mommy.recipe import seq 7 | from tests.models import Person 8 | from django.core.management import call_command 9 | 10 | 11 | @pytest.mark.django_db 12 | class TestCommand(object): 13 | 14 | @staticmethod 15 | def data(num_items=10): 16 | return mommy.make(Person, first_name=seq('first_name-'), last_name=seq('last_name-'), _quantity=num_items) 17 | 18 | def test_data_creation(self): 19 | self.data() 20 | assert Person.objects.count() == 10 21 | last = Person.objects.last() 22 | assert last.first_name == 'first_name-10' 23 | 24 | @pytest.mark.parametrize('num_items', [ 25 | 20000, 26 | 40000, 27 | ]) 28 | def test_simple_command(self, num_items): 29 | self.data(num_items=num_items) 30 | assert Person.objects.filter(first_name__startswith='first_name-').exists() 31 | call_command('anonymize_db') 32 | assert not Person.objects.filter(first_name__startswith='first_name-').exists() 33 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # For more information about tox, see https://tox.readthedocs.io/en/latest/ 2 | [tox] 3 | envlist = py36, flake8 4 | 5 | [flake8] 6 | max-line-length = 120 7 | 8 | [testenv] 9 | 10 | deps = 11 | pytest 12 | pytest-django 13 | model_mommy 14 | commands = pytest 15 | 16 | [testenv:flake8] 17 | deps = 18 | flake8 19 | basepython = python3.6 20 | commands = flake8 . --------------------------------------------------------------------------------