Repository: RegioHelden/django-scrubber (language: python)

Issue:
Question: How to scrub django base user?
Hi there!

Is there a way to define a scrubbing meta class for the base user without deriving from it and creating a new class?

Thx!


Discussion / hints:
I don't think that this is possible currently. On the other hand, Django itself recommend defining an own User model when starting a new project, as the other way around will require much of work.
If you want you can propose a PR with this feature, which might be useful for third party apps too, where the model is defined there.
You can get inspired by drf spectacular on how to extend schema for a third party app
https://drf-spectacular.readthedocs.io/en/latest/blueprints.html#blueprints
Actually, I don't have a good idea on how to solve this except of extending the default user model. Maybe we should add this thought to the docs? Scrubbing the user is one of the most obvious cases, right?
I haven't thought this through, but I maybe we could extend `SCRUBBER_GLOBAL_SCRUBBERS` to enable this.
How would you do this?
I think I have an idea. Providing a helper method which takes a model class and a scrubber class could be the solution. But avoiding code duplication will be the challenge.

Fix patch:
diff --git a/README.md b/README.md
index 6aba169..75faeae 100644
--- a/README.md
+++ b/README.md
@@ -146,6 +146,39 @@ Unfortunately, for performance reasons, the source data for scrubbing with faker
 
 When using `django < 2.1` and working on `sqlite` a bug within django causes field-specific scrubbing (e.g. `date_object`) to fail. Please consider using a different database backend or upgrade to the latest django version.
 
+## Scrubbing third-party models
+
+Sometimes you just don't have control over some code, but you still want to scrub the data of a given model.
+
+A good example is the Django user model. It contains sensitive data, and you would have to overwrite the whole model
+just to add the scrubber metaclass.
+
+That's the way to go:
+
+1. Define your Scrubber class **somewhere** in your codebase (like a `scrubbers.py`)
+
+```python
+# scrubbers.py
+class UserScrubbers:
+    scrubbers.Faker('de_DE')
+    first_name = scrubbers.Faker('first_name')
+    last_name = scrubbers.Faker('last_name')
+    username = scrubbers.Faker('uuid4')
+    password = scrubbers.Faker('sha1')
+    last_login = scrubbers.Null
+    email = scrubbers.Concat(first_name, models.Value('.'), last_name, models.Value('@'),
+                             models.Value(settings.SCRUBBER_DOMAIN))
+````
+
+2. Set up a mapping between your third-party model and your scrubber class
+
+```python
+# settings.py
+SCRUBBER_MAPPING = {
+    "auth.User": "apps.account.scrubbers.UserScrubbers",
+}
+```
+
 ## Settings
 
 ### `SCRUBBER_GLOBAL_SCRUBBERS`:
@@ -189,6 +222,19 @@ Set an alternative locale for Faker used during the scrubbing process.
 
 (default: None, falls back to Django's default locale)
 
+### `SCRUBBER_MAPPING`:
+
+Define a class and a mapper which does not have to live inside the given model. Useful, if you have no control over the
+models code you'd like to scrub.
+
+````python
+SCRUBBER_MAPPING = {
+    "auth.User": "my_app.scrubbers.UserScrubbers",
+}
+````
+
+(default: {})
+
 ## Logging
 
 Scrubber uses the default django logger. The logger name is ``django_scrubber.scrubbers``. 
diff --git a/django_scrubber/__init__.py b/django_scrubber/__init__.py
index c3dbdc3..f669342 100644
--- a/django_scrubber/__init__.py
+++ b/django_scrubber/__init__.py
@@ -10,6 +10,7 @@
     'SCRUBBER_APPS_LIST': None,
     'SCRUBBER_ADDITIONAL_FAKER_PROVIDERS': [],
     'SCRUBBER_FAKER_LOCALE': None,
+    'SCRUBBER_MAPPING': dict(),
 }
 
 
diff --git a/django_scrubber/management/commands/scrub_data.py b/django_scrubber/management/commands/scrub_data.py
index 1d09ea3..fb1b012 100644
--- a/django_scrubber/management/commands/scrub_data.py
+++ b/django_scrubber/management/commands/scrub_data.py
@@ -1,12 +1,13 @@
+import importlib
 import logging
 
+from django.apps import apps
 from django.conf import settings
 from django.contrib.sessions.models import Session
-from django.db.models import F
-from django.db.utils import IntegrityError, DataError
 from django.core.exceptions import FieldDoesNotExist
 from django.core.management.base import BaseCommand, CommandError
-from django.apps import apps
+from django.db.models import F
+from django.db.utils import IntegrityError, DataError
 
 from ... import settings_with_fallback
 from ...models import FakeData
@@ -99,13 +100,37 @@ def _call_callables(d):
     return {k.name: (callable(v) and v(k) or v) for k, v in d.items()}
 
 
+def _parse_scrubber_class_from_string(path: str):
+    """
+    Takes a string to a certain scrubber class and returns a python class definition - not an instance.
+    """
+    try:
+        module_name, class_name = path.rsplit('.', 1)
+        module = importlib.import_module(module_name)
+        return getattr(module, class_name)
+    except (ImportError, ValueError) as e:
+        raise ImportError('Mapped scrubber class "%s" could not be found.' % path) from e
+
+
 def _get_model_scrubbers(model):
+    # Get model-scrubber-mapping from settings
+    scrubber_mapping = settings_with_fallback('SCRUBBER_MAPPING')
+
+    # Initialise scrubber list
     scrubbers = dict()
-    try:
-        scrubber_cls = getattr(model, 'Scrubbers')
-    except AttributeError:
-        return scrubbers  # no model-specific scrubbers
 
+    # Check if model has a settings-defined...
+    if model._meta.label in scrubber_mapping:
+        scrubber_cls = _parse_scrubber_class_from_string(scrubber_mapping[model._meta.label])
+    # If not...
+    else:
+        # Try to get the scrubber metaclass from the given model
+        try:
+            scrubber_cls = getattr(model, 'Scrubbers')
+        except AttributeError:
+            return scrubbers  # no model-specific scrubbers
+
+    # Get field mappings from scrubber class
     for k, v in _get_fields(scrubber_cls):
         try:
             field = model._meta.get_field(k)
@@ -114,6 +139,7 @@ def _get_model_scrubbers(model):
 
         scrubbers[field] = v
 
+    # Return scrubber-field-mapping
     return scrubbers
 
 


Test patch:
diff --git a/tests/settings.py b/tests/settings.py
index bdb89bd..497ab4a 100644
--- a/tests/settings.py
+++ b/tests/settings.py
@@ -58,3 +58,5 @@
     MIDDLEWARE = ()
 else:
     MIDDLEWARE_CLASSES = ()
+
+DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
diff --git a/tests/test_scrub_data.py b/tests/test_scrub_data.py
index 5ebeca4..df814ca 100644
--- a/tests/test_scrub_data.py
+++ b/tests/test_scrub_data.py
@@ -5,10 +5,12 @@
 from io import StringIO
 
 from django.core.management import call_command
-from django.test import TestCase
+from django.test import TestCase, override_settings
 
 from django_scrubber import scrubbers
+from django_scrubber.management.commands.scrub_data import _parse_scrubber_class_from_string, _get_model_scrubbers
 from django.contrib.auth import get_user_model
+
 User = get_user_model()
 
 
@@ -50,3 +52,24 @@ class Scrubbers:
         self.user.refresh_from_db()
 
         self.assertNotEqual(self.user.first_name, 'test_first_name')
+
+    @override_settings(SCRUBBER_MAPPING={"auth.User": "tests.scrubbers.UserScrubbers"})
+    def test_get_model_scrubbers_mapper_from_settings_used(self):
+        with patch('django_scrubber.management.commands.scrub_data._parse_scrubber_class_from_string',
+                   return_value={}) as mocked_method:
+            with patch('django_scrubber.management.commands.scrub_data._get_fields', return_value=[]):
+                test_scrubbers = _get_model_scrubbers(User)
+        mocked_method.assert_called_once()
+        self.assertEqual(test_scrubbers, {})
+
+    def test_parse_scrubber_class_from_string_regular(self):
+        class_type = _parse_scrubber_class_from_string('tests.test_models.TestDjangoScrubber')
+        self.assertIsInstance(class_type, type)
+
+    def test_parse_scrubber_class_from_string_wrong_path(self):
+        with self.assertRaises(ImportError):
+            _parse_scrubber_class_from_string('not.valid.path')
+
+    def test_parse_scrubber_class_from_string_path_no_separator(self):
+        with self.assertRaises(ImportError):
+            _parse_scrubber_class_from_string('broken_path')