diff --git a/accounts/models.py b/accounts/models.py index e09b6c5517fc259e237798aec76c439ff42b8b68..92a6e259130206c29cca2f6a433b45281d5324cc 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -172,13 +172,21 @@ class User(AbstractBaseUser, PermissionsMixin): def default_mask_method(user): return "%s%s" % (_("User"), str(user.pk)) - from django.conf import settings - mask_method = getattr( - settings, - "RELATE_USER_PROFILE_MASK_METHOD", - default_mask_method) - - return str(mask_method(self)).strip() + from accounts.utils import relate_user_method_settings + mask_method = relate_user_method_settings.custom_profile_mask_method + if mask_method is None: + mask_method = default_mask_method + + # Intentionally don't fallback if it failed -- let user see the exception. + result = mask_method(self) + if not result: + raise RuntimeError("get_masked_profile should not None.") + else: + result = str(result).strip() + if not result: + raise RuntimeError("get_masked_profile should not return " + "an empty string.") + return result def get_short_name(self): "Returns the short name for the user." diff --git a/accounts/utils.py b/accounts/utils.py index b26662fda8dc62b68c83f30544d8113d887faed9..ddf8a29bb893d4f09e9a055ebc6333e1f4f41443 100644 --- a/accounts/utils.py +++ b/accounts/utils.py @@ -25,16 +25,17 @@ THE SOFTWARE. """ import six -from django.core.checks import Warning from django.utils.functional import cached_property from django.utils.module_loading import import_string -from relate.checks import INSTANCE_ERROR_PATTERN +from relate.checks import ( + INSTANCE_ERROR_PATTERN, Warning, RelateCriticalCheckMessage) from course.constants import DEFAULT_EMAIL_APPELLATION_PRIORITY_LIST RELATE_USER_FULL_NAME_FORMAT_METHOD = "RELATE_USER_FULL_NAME_FORMAT_METHOD" RELATE_EMAIL_APPELLATION_PRIORITY_LIST = ( "RELATE_EMAIL_APPELLATION_PRIORITY_LIST") +RELATE_USER_PROFILE_MASK_METHOD = "RELATE_USER_PROFILE_MASK_METHOD" class RelateUserMethodSettingsInitializer(object): @@ -47,6 +48,7 @@ class RelateUserMethodSettingsInitializer(object): self._custom_full_name_method = None self._email_appellation_priority_list = ( DEFAULT_EMAIL_APPELLATION_PRIORITY_LIST) + self._custom_profile_mask_method = None @cached_property def custom_full_name_method(self): @@ -58,6 +60,80 @@ class RelateUserMethodSettingsInitializer(object): self.check_email_appellation_priority_list() return self._email_appellation_priority_list + @cached_property + def custom_profile_mask_method(self): + self.check_user_profile_mask_method() + return self._custom_profile_mask_method + + def check_user_profile_mask_method(self): + self._custom_profile_mask_method = None + errors = [] + + from django.conf import settings + custom_user_profile_mask_method = getattr( + settings, RELATE_USER_PROFILE_MASK_METHOD, None) + + if custom_user_profile_mask_method is None: + return errors + + if isinstance(custom_user_profile_mask_method, six.string_types): + try: + custom_user_profile_mask_method = ( + import_string(custom_user_profile_mask_method)) + except ImportError: + errors = [RelateCriticalCheckMessage( + msg=( + "%(location)s: `%(method)s` failed to be imported. " + % {"location": RELATE_USER_PROFILE_MASK_METHOD, + "method": custom_user_profile_mask_method + } + ), + id="relate_user_profile_mask_method.E001" + )] + return errors + + self._custom_profile_mask_method = custom_user_profile_mask_method + if not callable(custom_user_profile_mask_method): + errors.append(RelateCriticalCheckMessage( + msg=( + "%(location)s: `%(method)s` is not a callable. " + % {"location": RELATE_USER_PROFILE_MASK_METHOD, + "method": custom_user_profile_mask_method + } + ), + id="relate_user_profile_mask_method.E002" + )) + else: + import inspect + if six.PY3: + sig = inspect.signature(custom_user_profile_mask_method) + n_args = len([p.name for p in sig.parameters.values()]) + else: + # Don't count the number of defaults. + # (getargspec returns args, varargs, varkw, defaults) + n_args = sum( + [len(arg) for arg + in inspect.getargspec(custom_user_profile_mask_method)[:3] + if arg is not None]) + + if not n_args or n_args > 1: + errors.append(RelateCriticalCheckMessage( + msg=( + "%(location)s: `%(method)s` should have exactly " + "one arg, got %(n)d." + % {"location": RELATE_USER_PROFILE_MASK_METHOD, + "method": custom_user_profile_mask_method, + "n": n_args + } + ), + id="relate_user_profile_mask_method.E003" + )) + + if errors: + self._custom_profile_mask_method = None + + return errors + def check_email_appellation_priority_list(self): errors = [] self._email_appellation_priority_list = ( diff --git a/accounts/views.py b/accounts/views.py index 6100593bc427cadce19dee2f7eb0c7f1473a8ad9..0e824d63b7d0b94c018412f4d472f0fd137c3f57 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,3 +1,3 @@ -from django.shortcuts import render # noqa +# from django.shortcuts import render # noqa # Create your views here. diff --git a/local_settings_example.py b/local_settings_example.py index 9d0b40ba04c0094bb7734fc4856c3609cf7c06da..2cf368621da94ec046fac6e0b7240abefd40ad02 100644 --- a/local_settings_example.py +++ b/local_settings_example.py @@ -224,7 +224,7 @@ RELATE_SHOW_EDITOR_FORM = True # For example, you can define it like this: #<code> -# def my_fullname_format(firstname, lastname) +# def my_fullname_format(firstname, lastname): # return "%s%s" % (last_name, first_name) #</code> @@ -234,7 +234,7 @@ RELATE_SHOW_EDITOR_FORM = True # You can also import it from your custom module, or use a dotted path of the # method, i.e.: -# RELATE_USER_FULL_NAME_FORMAT_METHOD = "path.to.my_fullname_format" +#RELATE_USER_FULL_NAME_FORMAT_METHOD = "path.to.my_fullname_format" # }}} @@ -256,6 +256,31 @@ RELATE_SHOW_EDITOR_FORM = True # }}} +# {{{ custom method for masking user profile +# When a participation, for example, teaching assistant, has limited access to +# students' profile (i.e., has_permission(pperm.view_participant_masked_profile)), +# a built-in mask method (which is based on pk of user instances) is used be +# default. The mask method can be overriden by the following a custom method, with +# user as the args. + +#RELATE_USER_PROFILE_MASK_METHOD = "path.tomy_method +# For example, you can define it like this: + +#<code> +# def my_mask_method(user): +# return "User_%s" % str(user.pk + 100) +#</code> + +# and then uncomment the following line and enable it with: + +#RELATE_USER_PROFILE_MASK_METHOD = my_mask_method + +# You can also import it from your custom module, or use a dotted path of the +# method, i.e.: +#RELATE_USER_PROFILE_MASK_METHOD = "path.to.my_mask_method" + +# }}} + # {{{ extra checks # This allow user to add customized startup checkes for user-defined modules diff --git a/relate/checks.py b/relate/checks.py index b4761fe0516962b6408a23161690063c9321eb02..afff40b942b1956377c9533efd8b704be67268bb 100644 --- a/relate/checks.py +++ b/relate/checks.py @@ -108,6 +108,9 @@ def check_relate_settings(app_configs, **kwargs): # check RELATE_CSV_SETTINGS errors.extend(relate_user_method_settings.check_custom_full_name_method()) + # check RELATE_USER_PROFILE_MASK_METHOD + errors.extend(relate_user_method_settings.check_user_profile_mask_method()) + # {{{ check EMAIL_CONNECTIONS email_connections = getattr(settings, EMAIL_CONNECTIONS, None) if email_connections is not None: diff --git a/tests/resource.py b/tests/resource.py index 0985a7ef7719b2d3eb4cca68f66528df212b4610..3f78438b5a2d60d2fa80bbcac5d3bb171e8cc67f 100644 --- a/tests/resource.py +++ b/tests/resource.py @@ -27,8 +27,23 @@ def my_customized_get_full_name_method(first_name, last_name): return "%s %s" % (first_name.title(), last_name.title()) -def my_customized_get_full_name_method_invalid(first_name, last_name): # noqa +def my_customized_get_full_name_method_invalid(first_name, last_name): return None my_customized_get_full_name_method_invalid_str = "some_string" + + +def my_custom_get_masked_profile_method_valid(u): + return "%s%s" % ("User", str(u.pk + 100)) + + +my_custom_get_masked_profile_method_invalid_str = "some_string" + + +def my_custom_get_masked_profile_method_valid_but_return_none(u): + return + + +def my_custom_get_masked_profile_method_valid_but_return_emtpy_string(u): + return " " diff --git a/tests/test_accounts/test_models.py b/tests/test_accounts/test_models.py index 2c6a4ef3b9bb04ab7254c27b5d767dfd5762b0a8..6ec84f534684269e576c047d2bc17d4bc319857c 100644 --- a/tests/test_accounts/test_models.py +++ b/tests/test_accounts/test_models.py @@ -252,3 +252,26 @@ class UserModelTest(TestCase): RELATE_EMAIL_APPELLATION_PRIORITY_LIST=["full_name"], RELATE_USER_FULL_NAME_FORMAT_METHOD=None): self.assertEqual(user.get_email_appellation(), "my_first my_last") + + def test_user_profile_mask_method_is_cached(self): + user = UserFactory.create(first_name="my_first", last_name="my_last") + + from accounts.utils import relate_user_method_settings + + user_profile_mask_method_check_path = ( + "accounts.utils.RelateUserMethodSettingsInitializer" + ".check_user_profile_mask_method") + + def custom_method(u): + return "%s%s" % ("User", str(u.pk + 100)) + + with override_settings(RELATE_USER_PROFILE_MASK_METHOD=custom_method): + relate_user_method_settings.__dict__ = {} + self.assertEqual(user.get_masked_profile(), custom_method(user)) + + user2 = UserFactory.create(first_name="my_first") + + with mock.patch(user_profile_mask_method_check_path) as mock_check: + self.assertEqual(user2.get_masked_profile(), + custom_method(user2)) + self.assertEqual(mock_check.call_count, 0) diff --git a/tests/test_checks.py b/tests/test_checks.py index f298233d1fe993d510bc66e5adbbf4ca5b4d8e6f..3749762cb577738bed49999e315a45e48d2d8200 100644 --- a/tests/test_checks.py +++ b/tests/test_checks.py @@ -130,6 +130,135 @@ class CheckRelateURL(CheckRelateSettingsBase): self.assertCheckMessages(["relate_base_url.E003"]) +class CheckRelateUserProfileMaskMethod(CheckRelateSettingsBase): + # This TestCase is not pure for check, but also make sure it returned + # expected result + allow_database_queries = True + + msg_id_prefix = "relate_user_profile_mask_method" + + def setUp(self): + super(CheckRelateUserProfileMaskMethod, self).setUp() + self.user = UserFactory.create(first_name="my_first", last_name="my_last") + + from accounts.utils import relate_user_method_settings + relate_user_method_settings.__dict__ = {} + + def test_get_masked_profile_not_configured(self): + with override_settings(): + del settings.RELATE_USER_PROFILE_MASK_METHOD + self.assertCheckMessages([]) + + # make sure it runs without issue + self.assertIsNotNone(self.user.get_masked_profile()) + + def test_get_masked_profile_valid_none(self): + with override_settings(RELATE_USER_PROFILE_MASK_METHOD=None): + self.assertCheckMessages([]) + + # make sure it runs without issue + self.assertIsNotNone(self.user.get_masked_profile()) + + def test_get_masked_profile_valid_method1(self): + def custom_method(u): + return "%s%s" % ("User", str(u.pk + 1)) + + with override_settings(RELATE_USER_PROFILE_MASK_METHOD=custom_method): + self.assertCheckMessages([]) + self.assertEqual(self.user.get_masked_profile(), + custom_method(self.user)) + + def test_get_masked_profile_valid_method2(self): + def custom_method(user=None): + if user is not None: + return "%s%s" % ("User", str(user.pk + 1)) + else: + return "" + + with override_settings(RELATE_USER_PROFILE_MASK_METHOD=custom_method): + self.assertCheckMessages([]) + self.assertEqual(self.user.get_masked_profile(), + custom_method(self.user)) + + def test_get_masked_profile_valid_method_path(self): + with override_settings( + RELATE_USER_PROFILE_MASK_METHOD=( + "tests.resource" + ".my_custom_get_masked_profile_method_valid")): + self.assertCheckMessages([]) + from tests.resource import ( + my_custom_get_masked_profile_method_valid as custom_method) + self.assertEqual(self.user.get_masked_profile(), + custom_method(self.user)) + + def test_get_masked_profile_param_invalid1(self): + # the method has 0 args/kwargs + def custom_method(): + return "profile" + + with override_settings(RELATE_USER_PROFILE_MASK_METHOD=custom_method): + self.assertCheckMessages(['relate_user_profile_mask_method.E003']) + + def test_get_masked_profile_param_invalid2(self): + # the method has 2 args/kwargs + def custom_method(u, v): + return "%s%s" % ("User", str(u.pk + 1)) + + with override_settings(RELATE_USER_PROFILE_MASK_METHOD=custom_method): + self.assertCheckMessages(['relate_user_profile_mask_method.E003']) + + def test_get_masked_profile_param_invalid3(self): + # the method has 2 args/kwargs + def custom_method(u, v=None): + return "%s%s" % ("User", str(u.pk + 1)) + + with override_settings(RELATE_USER_PROFILE_MASK_METHOD=custom_method): + self.assertCheckMessages(['relate_user_profile_mask_method.E003']) + + def test_get_masked_profile_invalid_path(self): + with override_settings(RELATE_USER_PROFILE_MASK_METHOD="invalid path"): + self.assertCheckMessages(['relate_user_profile_mask_method.E001']) + + def test_get_masked_profile_valid_path_not_callable(self): + with override_settings( + RELATE_USER_PROFILE_MASK_METHOD=( + "tests.resource" + ".my_custom_get_masked_profile_method_invalid_str")): + self.assertCheckMessages(['relate_user_profile_mask_method.E002']) + + def test_passed_check_but_return_none(self): + with override_settings( + RELATE_USER_PROFILE_MASK_METHOD=( + "tests.resource" + ".my_custom_get_masked_profile_method_valid_but_return_none")): # noqa + self.assertCheckMessages([]) + from tests.resource import ( + my_custom_get_masked_profile_method_valid_but_return_none + as custom_method) + + # test method can run + custom_method(self.user) + + with self.assertRaises(RuntimeError): + self.user.get_masked_profile() + + def test_passed_check_but_return_empty_string(self): + with override_settings( + RELATE_USER_PROFILE_MASK_METHOD=( + "tests.resource" + ".my_custom_get_masked_profile_method_valid_but_return_emtpy_string")): # noqa + self.assertCheckMessages([]) + from tests.resource import ( + my_custom_get_masked_profile_method_valid_but_return_emtpy_string + as custom_method) + + # test method can run + custom_method(self.user) + + with self.assertRaises(RuntimeError): + self.user.get_masked_profile() + + class CheckRelateUserFullNameFormatMethod(CheckRelateSettingsBase): # This TestCase is not pure for check, but also make sure it returned # expected result @@ -145,19 +274,19 @@ class CheckRelateUserFullNameFormatMethod(CheckRelateSettingsBase): def invalid_method1(first_name): return first_name - def invalid_method2(first_name, last_name): # noqa + def invalid_method2(first_name, last_name): return None - def invalid_method3(first_name, last_name): # noqa + def invalid_method3(first_name, last_name): return " " - def invalid_method4(first_name, last_name): # noqa + def invalid_method4(first_name, last_name): return b"my_name" - def invalid_method5(first_name, last_name): # noqa + def invalid_method5(first_name, last_name): return "my_name" - def invalid_method6(first_name, last_name): # noqa + def invalid_method6(first_name, last_name): return Exception() default_user_dict = {"first_name": "first_name", "last_name": "last_name"}