diff --git a/kolibri/core/auth/api.py b/kolibri/core/auth/api.py index d025e33579c..3ed56db2cd6 100644 --- a/kolibri/core/auth/api.py +++ b/kolibri/core/auth/api.py @@ -413,6 +413,7 @@ class FacilityUserViewSet(ValuesViewset): "id_number", "gender", "birth_year", + "extra_demographics", ) field_map = { diff --git a/kolibri/core/auth/constants/demographics.py b/kolibri/core/auth/constants/demographics.py index b9f9a923590..094b942361e 100644 --- a/kolibri/core/auth/constants/demographics.py +++ b/kolibri/core/auth/constants/demographics.py @@ -1,5 +1,13 @@ from __future__ import unicode_literals +from django.core.exceptions import ValidationError +from django.utils.deconstruct import deconstructible + +from kolibri.core.utils.validators import JSON_Schema_Validator +from kolibri.core.utils.validators import NoRepeatedValueJSONArrayValidator +from kolibri.utils.i18n import KOLIBRI_SUPPORTED_LANGUAGES + + MALE = "MALE" FEMALE = "FEMALE" NOT_SPECIFIED = "NOT_SPECIFIED" @@ -14,3 +22,148 @@ ) DEMO_FIELDS = ("gender", "birth_year", "id_number") + + +# '"optional":True' is obsolete but needed while we keep using an +# old json_schema_validator version compatible with python 2.7. +# "additionalProperties": False must be avoided for backwards compatibility +translations_schema = { + "type": "array", + "items": { + "type": "object", + "properties": { + # KOLIBRI_SUPPORTED_LANGUAGES is a set of strings, so we use sorted + # to coerce it to a list with a consistent ordering. + # If we don't do this, every time we initialize, Django thinks it has changed + # and will try to create a new migration. + "language": {"type": "string", "enum": sorted(KOLIBRI_SUPPORTED_LANGUAGES)}, + "message": {"type": "string"}, + }, + }, + "optional": True, +} + + +custom_demographic_field_schema = { + "type": "object", + "properties": { + "id": { + "type": "string", + }, + "description": {"type": "string"}, + "enumValues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "value": {"type": "string"}, + "defaultLabel": {"type": "string"}, + "translations": translations_schema, + }, + }, + }, + "translations": translations_schema, + }, +} + + +custom_demographics_schema = { + "type": "array", + "items": custom_demographic_field_schema, + "optional": True, +} + + +@deconstructible +class UniqueIdsValidator(NoRepeatedValueJSONArrayValidator): + def __init__(self, custom_demographics_key): + super(UniqueIdsValidator, self).__init__( + array_key=custom_demographics_key, object_key="id" + ) + + +unique_translations_validator = NoRepeatedValueJSONArrayValidator( + array_key="translations", + object_key="language", +) + + +@deconstructible +class DescriptionTranslationValidator(object): + def __init__(self, custom_demographics_key): + self.custom_demographics_key = custom_demographics_key + + def __call__(self, value): + for item in value.get(self.custom_demographics_key, []): + try: + unique_translations_validator(item) + except ValidationError: + raise ValidationError( + "User facing description translations for '{} ({})' must be unique by language".format( + item["description"], item["id"] + ), + code="invalid", + ) + return value + + +unique_value_validator = NoRepeatedValueJSONArrayValidator( + object_key="value", +) + + +@deconstructible +class EnumValuesValidator(object): + def __init__(self, custom_demographics_key): + self.custom_demographics_key = custom_demographics_key + + def __call__(self, value): + for item in value.get(self.custom_demographics_key, []): + enum_values = item.get("enumValues", []) + try: + unique_value_validator(enum_values) + except ValidationError: + raise ValidationError( + "Possible values for '{} ({})' must be unique".format( + item["description"], item["id"] + ), + code="invalid", + ) + return value + + +@deconstructible +class LabelTranslationValidator(object): + def __init__(self, custom_demographics_key): + self.custom_demographics_key = custom_demographics_key + + def __call__(self, value): + for item in value.get(self.custom_demographics_key, []): + for enumValue in item.get("enumValues", []): + try: + unique_translations_validator(enumValue) + except ValidationError: + raise ValidationError( + "User facing label translations for value '{} ({})' in '{} ({})' must be unique by language".format( + enumValue["defaultLabel"], + enumValue["value"], + item["description"], + item["id"], + ), + code="invalid", + ) + return value + + +class FacilityUserDemographicValidator(JSON_Schema_Validator): + def __init__(self, custom_schema): + schema = { + "type": "object", + "properties": {}, + } + for field in custom_schema: + schema["properties"][field["id"]] = { + "type": "string", + "enum": [enum["value"] for enum in field["enumValues"]], + } + super(FacilityUserDemographicValidator, self).__init__(schema) diff --git a/kolibri/core/auth/migrations/0025_custom_demographic_schema.py b/kolibri/core/auth/migrations/0025_custom_demographic_schema.py new file mode 100644 index 00000000000..c60cc06a53d --- /dev/null +++ b/kolibri/core/auth/migrations/0025_custom_demographic_schema.py @@ -0,0 +1,182 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.29 on 2024-03-28 15:34 +from __future__ import unicode_literals + +from django.db import migrations + +import kolibri.core.auth.constants.demographics +import kolibri.core.fields +import kolibri.core.utils.validators + + +class Migration(migrations.Migration): + + dependencies = [ + ("kolibriauth", "0024_extend_username_length"), + ] + + operations = [ + migrations.AddField( + model_name="facilityuser", + name="extra_demographics", + field=kolibri.core.fields.JSONField(blank=True, null=True), + ), + migrations.AlterField( + model_name="facilitydataset", + name="extra_fields", + field=kolibri.core.fields.JSONField( + blank=True, + default={"facility": {}, "on_my_own_setup": False, "pin_code": ""}, + null=True, + validators=[ + kolibri.core.utils.validators.JSON_Schema_Validator( + { + "properties": { + "demographic_fields": { + "items": { + "properties": { + "description": {"type": "string"}, + "enumValues": { + "items": { + "properties": { + "defaultLabel": { + "type": "string" + }, + "translations": { + "items": { + "properties": { + "language": { + "enum": [ + "ar", + "bg-bg", + "bn-bd", + "de", + "el", + "en", + "es-419", + "es-es", + "fa", + "ff-cm", + "fr-fr", + "gu-in", + "ha", + "hi-in", + "ht", + "id", + "it", + "ka", + "km", + "ko", + "mr", + "my", + "nyn", + "pt-br", + "pt-mz", + "sw-tz", + "te", + "uk", + "ur-pk", + "vi", + "yo", + "zh-hans", + ], + "type": "string", + }, + "message": { + "type": "string" + }, + }, + "type": "object", + }, + "optional": True, + "type": "array", + }, + "value": {"type": "string"}, + }, + "type": "object", + }, + "type": "array", + }, + "id": {"type": "string"}, + "translations": { + "items": { + "properties": { + "language": { + "enum": [ + "ar", + "bg-bg", + "bn-bd", + "de", + "el", + "en", + "es-419", + "es-es", + "fa", + "ff-cm", + "fr-fr", + "gu-in", + "ha", + "hi-in", + "ht", + "id", + "it", + "ka", + "km", + "ko", + "mr", + "my", + "nyn", + "pt-br", + "pt-mz", + "sw-tz", + "te", + "uk", + "ur-pk", + "vi", + "yo", + "zh-hans", + ], + "type": "string", + }, + "message": {"type": "string"}, + }, + "type": "object", + }, + "optional": True, + "type": "array", + }, + }, + "type": "object", + }, + "optional": True, + "type": "array", + }, + "facility": {"optional": True, "type": "object"}, + "on_my_own_setup": { + "optional": True, + "type": "boolean", + }, + "pin_code": { + "optional": True, + "type": ["string", "null"], + }, + }, + "type": "object", + } + ), + kolibri.core.auth.constants.demographics.UniqueIdsValidator( + "demographic_fields" + ), + kolibri.core.auth.constants.demographics.DescriptionTranslationValidator( + "demographic_fields" + ), + kolibri.core.auth.constants.demographics.EnumValuesValidator( + "demographic_fields" + ), + kolibri.core.auth.constants.demographics.LabelTranslationValidator( + "demographic_fields" + ), + ], + ), + ), + ] diff --git a/kolibri/core/auth/models.py b/kolibri/core/auth/models.py index 01a972cf4be..2686e83a128 100644 --- a/kolibri/core/auth/models.py +++ b/kolibri/core/auth/models.py @@ -68,8 +68,14 @@ from .permissions.general import IsSelf from kolibri.core import error_constants from kolibri.core.auth.constants.demographics import choices as GENDER_CHOICES +from kolibri.core.auth.constants.demographics import custom_demographics_schema from kolibri.core.auth.constants.demographics import DEFERRED +from kolibri.core.auth.constants.demographics import DescriptionTranslationValidator +from kolibri.core.auth.constants.demographics import EnumValuesValidator +from kolibri.core.auth.constants.demographics import FacilityUserDemographicValidator +from kolibri.core.auth.constants.demographics import LabelTranslationValidator from kolibri.core.auth.constants.demographics import NOT_SPECIFIED +from kolibri.core.auth.constants.demographics import UniqueIdsValidator from kolibri.core.auth.constants.morango_sync import ScopeDefinitions from kolibri.core.device.utils import device_provisioned from kolibri.core.device.utils import get_device_setting @@ -84,6 +90,9 @@ logger = logging.getLogger(__name__) +DEMOGRAPHIC_FIELDS_KEY = "demographic_fields" + + # '"optional":True' is obsolete but needed while we keep using an # old json_schema_validator version compatible with python 2.7. # "additionalProperties": False must be avoided for backwards compatibility @@ -93,6 +102,7 @@ "facility": {"type": "object", "optional": True}, "on_my_own_setup": {"type": "boolean", "optional": True}, "pin_code": {"type": ["string", "null"], "optional": True}, + DEMOGRAPHIC_FIELDS_KEY: custom_demographics_schema, }, } @@ -185,7 +195,13 @@ class FacilityDataset(FacilityDataSyncableModel): extra_fields = JSONField( null=True, blank=True, - validators=[JSON_Schema_Validator(extra_fields_schema)], + validators=[ + JSON_Schema_Validator(extra_fields_schema), + UniqueIdsValidator(DEMOGRAPHIC_FIELDS_KEY), + DescriptionTranslationValidator(DEMOGRAPHIC_FIELDS_KEY), + EnumValuesValidator(DEMOGRAPHIC_FIELDS_KEY), + LabelTranslationValidator(DEMOGRAPHIC_FIELDS_KEY), + ], default=extra_fields_default_values, ) registered = models.BooleanField(default=False) @@ -256,6 +272,14 @@ def full_facility_import(self): """ return is_full_facility_import(self.id) + def validate_demographic_data(self, demographic_data): + """ + Use the custom schema to validate demographic data being set on a FacilityUser. + """ + FacilityUserDemographicValidator(self.extra_fields[DEMOGRAPHIC_FIELDS_KEY])( + demographic_data + ) + class AbstractFacilityDataModel(FacilityDataSyncableModel): """ @@ -807,6 +831,14 @@ class FacilityUser(KolibriAbstractBaseUser, AbstractFacilityDataModel): id_number = models.CharField(max_length=64, default="", blank=True) + extra_demographics = JSONField( + # We deliberately do no validation on this field, to avoid user data being stuck + # and unserializable if the Facility's demographic schema has been updated, but + # the user data now conflicts with that. + null=True, + blank=True, + ) + @classmethod def deserialize(cls, dict_model): # be defensive against blank passwords, set to `NOT_SPECIFIED` if blank @@ -1466,7 +1498,20 @@ def ensure_dataset(self, *args, **kwargs): def infer_dataset(self, *args, **kwargs): # if we don't yet have a dataset, create a new one for this facility if not self.dataset_id: - self.dataset = FacilityDataset.objects.create() + from kolibri.core.device.models import DEFAULT_DEMOGRAPHIC_FIELDS_KEY + + kwargs = {} + + default_demographic_fields = get_device_setting( + DEFAULT_DEMOGRAPHIC_FIELDS_KEY + ) + + if default_demographic_fields: + kwargs["extra_fields"] = { + DEMOGRAPHIC_FIELDS_KEY: default_demographic_fields + } + + self.dataset = FacilityDataset.objects.create(**kwargs) return self.dataset_id def get_classrooms(self): diff --git a/kolibri/core/auth/serializers.py b/kolibri/core/auth/serializers.py index 6bc8c9fbc26..eae42791e4d 100644 --- a/kolibri/core/auth/serializers.py +++ b/kolibri/core/auth/serializers.py @@ -37,6 +37,7 @@ class FacilityUserSerializer(serializers.ModelSerializer): required=False, error_messages={"does_not_exist": "Facility does not exist."}, ) + extra_demographics = serializers.JSONField(required=False) class Meta: model = FacilityUser @@ -52,6 +53,7 @@ class Meta: "id_number", "gender", "birth_year", + "extra_demographics", ) read_only_fields = ("is_superuser",) @@ -64,6 +66,15 @@ def save(self, **kwargs): instance.save() return instance + def _validate_extra_demographics(self, attrs, facility): + # Validate the extra demographics here, as we need access to the facility dataset + extra_demographics = attrs.get("extra_demographics") + if extra_demographics: + try: + facility.dataset.validate_demographic_data(extra_demographics) + except DjangoValidationError as e: + raise serializers.ValidationError({"extra_demographics": e.message}) + def validate(self, attrs): username = attrs.get("username", None) if username is not None: @@ -91,6 +102,8 @@ def validate(self, attrs): "No password specified and it is required", code=error_constants.PASSWORD_NOT_SPECIFIED, ) + self._validate_extra_demographics(attrs, facility) + # if obj doesn't exist, return data try: obj = FacilityUser.objects.get(username__iexact=username, facility=facility) diff --git a/kolibri/core/auth/test/test_api.py b/kolibri/core/auth/test/test_api.py index e0d11baad23..5ea87b39b33 100644 --- a/kolibri/core/auth/test/test_api.py +++ b/kolibri/core/auth/test/test_api.py @@ -682,6 +682,33 @@ def test_public_facilityuser_endpoint(self): ) +def _add_demographic_schema_to_facility(facility): + facility.dataset.extra_fields.update( + { + models.DEMOGRAPHIC_FIELDS_KEY: [ + { + "id": "status", + "description": "Up or Down", + "enumValues": [ + { + "value": "up", + "defaultLabel": "Up", + "translations": [{"language": "en", "message": "Up"}], + }, + { + "value": "down", + "defaultLabel": "Down", + "translations": [{"language": "en", "message": "Down"}], + }, + ], + "translations": [{"language": "en", "message": "Up or Down"}], + } + ] + } + ) + facility.dataset.save() + + class UserCreationTestCase(APITestCase): @classmethod def setUpTestData(cls): @@ -777,6 +804,32 @@ def test_max_length_username_in_api(self): self.assertEqual(response.data[0]["id"], error_constants.MAX_LENGTH) self.assertEqual(response.data[0]["metadata"]["field"], "username") + def test_can_add_extra_demographics_to_facility_user(self): + _add_demographic_schema_to_facility(self.facility) + data = { + "username": "goliath", + "password": "davidsucks", + "extra_demographics": {"status": "up"}, + } + response = self.client.post( + reverse("kolibri:core:facilityuser-list"), data, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["extra_demographics"], {"status": "up"}) + + def test_cant_add_invalid_extra_demographics_to_facility_user(self): + _add_demographic_schema_to_facility(self.facility) + data = { + "username": "goliath", + "password": "davidsucks", + "extra_demographics": {"status": "invalid"}, + } + response = self.client.post( + reverse("kolibri:core:facilityuser-list"), data, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data[0]["metadata"]["field"], "extra_demographics") + class UserUpdateTestCase(APITestCase): @classmethod @@ -855,6 +908,50 @@ def test_updating_same_user_same_username_case_insensitive(self): ).exists() ) + def test_updating_extra_demographics_previously_none(self): + _add_demographic_schema_to_facility(self.facility) + response = self.client.patch( + reverse("kolibri:core:facilityuser-detail", kwargs={"pk": self.user.pk}), + {"extra_demographics": {"status": "up"}}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["extra_demographics"], {"status": "up"}) + + def test_updating_extra_demographics_previously_set(self): + _add_demographic_schema_to_facility(self.facility) + self.user.extra_fields = {"status": "down"} + self.user.save() + response = self.client.patch( + reverse("kolibri:core:facilityuser-detail", kwargs={"pk": self.user.pk}), + {"extra_demographics": {"status": "up"}}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["extra_demographics"], {"status": "up"}) + + def test_updating_extra_demographics_previously_none_invalid_value(self): + _add_demographic_schema_to_facility(self.facility) + response = self.client.patch( + reverse("kolibri:core:facilityuser-detail", kwargs={"pk": self.user.pk}), + {"extra_demographics": {"status": "invalid"}}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data[0]["metadata"]["field"], "extra_demographics") + + def test_updating_extra_demographics_previously_set_invalid_value(self): + _add_demographic_schema_to_facility(self.facility) + self.user.extra_fields = {"status": "down"} + self.user.save() + response = self.client.patch( + reverse("kolibri:core:facilityuser-detail", kwargs={"pk": self.user.pk}), + {"extra_demographics": {"status": "invalid"}}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data[0]["metadata"]["field"], "extra_demographics") + class UserDeleteTestCase(APITestCase): @classmethod @@ -921,6 +1018,7 @@ def test_user_list(self): "birth_year": self.user.birth_year, "is_superuser": False, "roles": [], + "extra_demographics": None, }, { "id": self.superuser.id, @@ -938,6 +1036,7 @@ def test_user_list(self): "id": self.superuser.roles.first().id, } ], + "extra_demographics": None, }, ], ) @@ -963,6 +1062,7 @@ def test_user_list_self(self): "birth_year": self.user.birth_year, "is_superuser": False, "roles": [], + "extra_demographics": None, }, ], ) diff --git a/kolibri/core/auth/test/test_demographic_validation.py b/kolibri/core/auth/test/test_demographic_validation.py new file mode 100644 index 00000000000..320598e0af8 --- /dev/null +++ b/kolibri/core/auth/test/test_demographic_validation.py @@ -0,0 +1,143 @@ +import unittest + +from django.core.exceptions import ValidationError + +from kolibri.core.auth.constants.demographics import DescriptionTranslationValidator +from kolibri.core.auth.constants.demographics import EnumValuesValidator +from kolibri.core.auth.constants.demographics import LabelTranslationValidator +from kolibri.core.auth.constants.demographics import UniqueIdsValidator + + +class TestDemographicValidation(unittest.TestCase): + def test_validate_unique_objects(self): + validator = UniqueIdsValidator("custom_demographics") + with self.assertRaises(ValidationError): + validator({"custom_demographics": [{"id": "1"}, {"id": "1"}]}) + validator({"custom_demographics": [{"id": "1"}, {"id": "2"}]}) + + def test_validate_unique_description_translations(self): + validator = DescriptionTranslationValidator("custom_demographics") + with self.assertRaises(ValidationError): + validator( + { + "custom_demographics": [ + { + "id": "1", + "description": "description", + "translations": [{"language": "en"}, {"language": "en"}], + } + ] + } + ) + validator( + { + "custom_demographics": [ + { + "id": "1", + "description": "description", + "translations": [{"language": "en"}, {"language": "fr"}], + } + ] + } + ) + + def test_validate_unique_description_no_translations(self): + validator = DescriptionTranslationValidator("custom_demographics") + try: + validator( + {"custom_demographics": [{"id": "1", "description": "description"}]} + ) + except Exception: + self.fail("Unexpected exception raised") + + def test_validate_unique_values(self): + validator = EnumValuesValidator("custom_demographics") + with self.assertRaises(ValidationError): + validator( + { + "custom_demographics": [ + { + "id": "1", + "description": "description", + "enumValues": [{"value": "1"}, {"value": "1"}], + } + ] + } + ) + validator( + { + "custom_demographics": [ + { + "id": "1", + "description": "description", + "enumValues": [{"value": "1"}, {"value": "2"}], + } + ] + } + ) + + def test_validate_unique_label_translations(self): + validator = LabelTranslationValidator("custom_demographics") + with self.assertRaises(ValidationError): + validator( + { + "custom_demographics": [ + { + "id": "1", + "description": "description", + "enumValues": [ + { + "value": "1", + "defaultLabel": "test", + "translations": [ + {"language": "en"}, + {"language": "en"}, + ], + }, + {"value": "2"}, + ], + } + ] + } + ) + validator( + { + "custom_demographics": [ + { + "id": "1", + "description": "description", + "enumValues": [ + { + "value": "1", + "defaultLabel": "test", + "translations": [ + {"language": "en"}, + {"language": "fr"}, + ], + }, + {"value": "2"}, + ], + } + ] + } + ) + + def test_validate_unique_label_no_translations(self): + validator = LabelTranslationValidator("custom_demographics") + try: + validator( + { + "custom_demographics": [ + { + "id": "1", + "description": "description", + "enumValues": [ + {"value": "1", "defaultLabel": "test"}, + {"value": "2"}, + ], + } + ] + } + ) + except Exception: + self.fail("Unexpected exception raised") diff --git a/kolibri/core/device/migrations/0021_default_demographic_fields.py b/kolibri/core/device/migrations/0021_default_demographic_fields.py new file mode 100644 index 00000000000..98a4a74fa9d --- /dev/null +++ b/kolibri/core/device/migrations/0021_default_demographic_fields.py @@ -0,0 +1,192 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.29 on 2024-03-28 15:35 +from __future__ import unicode_literals + +from django.db import migrations + +import kolibri.core.auth.constants.demographics +import kolibri.core.fields +import kolibri.core.utils.validators + + +class Migration(migrations.Migration): + + dependencies = [ + ("device", "0020_fix_learner_device_status_choices"), + ] + + operations = [ + migrations.AlterField( + model_name="devicesettings", + name="extra_settings", + field=kolibri.core.fields.JSONField( + default={ + "allow_download_on_metered_connection": False, + "allow_learner_download_resources": False, + "enable_automatic_download": True, + "limit_for_autodownload": 0, + "set_limit_for_autodownload": False, + }, + validators=[ + kolibri.core.utils.validators.JSON_Schema_Validator( + { + "properties": { + "allow_download_on_metered_connection": { + "type": "boolean" + }, + "allow_learner_download_resources": { + "optional": True, + "type": "boolean", + }, + "default_demographic_field_schema": { + "items": { + "properties": { + "description": {"type": "string"}, + "enumValues": { + "items": { + "properties": { + "defaultLabel": { + "type": "string" + }, + "translations": { + "items": { + "properties": { + "language": { + "enum": [ + "ar", + "bg-bg", + "bn-bd", + "de", + "el", + "en", + "es-419", + "es-es", + "fa", + "ff-cm", + "fr-fr", + "gu-in", + "ha", + "hi-in", + "ht", + "id", + "it", + "ka", + "km", + "ko", + "mr", + "my", + "nyn", + "pt-br", + "pt-mz", + "sw-tz", + "te", + "uk", + "ur-pk", + "vi", + "yo", + "zh-hans", + ], + "type": "string", + }, + "message": { + "type": "string" + }, + }, + "type": "object", + }, + "optional": True, + "type": "array", + }, + "value": {"type": "string"}, + }, + "type": "object", + }, + "type": "array", + }, + "id": {"type": "string"}, + "translations": { + "items": { + "properties": { + "language": { + "enum": [ + "ar", + "bg-bg", + "bn-bd", + "de", + "el", + "en", + "es-419", + "es-es", + "fa", + "ff-cm", + "fr-fr", + "gu-in", + "ha", + "hi-in", + "ht", + "id", + "it", + "ka", + "km", + "ko", + "mr", + "my", + "nyn", + "pt-br", + "pt-mz", + "sw-tz", + "te", + "uk", + "ur-pk", + "vi", + "yo", + "zh-hans", + ], + "type": "string", + }, + "message": {"type": "string"}, + }, + "type": "object", + }, + "optional": True, + "type": "array", + }, + }, + "type": "object", + }, + "optional": True, + "type": "array", + }, + "enable_automatic_download": {"type": "boolean"}, + "limit_for_autodownload": { + "optional": True, + "type": "integer", + }, + "set_limit_for_autodownload": { + "optional": True, + "type": "boolean", + }, + }, + "required": [ + "allow_download_on_metered_connection", + "enable_automatic_download", + ], + "type": "object", + } + ), + kolibri.core.auth.constants.demographics.UniqueIdsValidator( + "default_demographic_field_schema" + ), + kolibri.core.auth.constants.demographics.DescriptionTranslationValidator( + "default_demographic_field_schema" + ), + kolibri.core.auth.constants.demographics.EnumValuesValidator( + "default_demographic_field_schema" + ), + kolibri.core.auth.constants.demographics.LabelTranslationValidator( + "default_demographic_field_schema" + ), + ], + ), + ), + ] diff --git a/kolibri/core/device/models.py b/kolibri/core/device/models.py index 8e7e12c2322..5427455c24e 100644 --- a/kolibri/core/device/models.py +++ b/kolibri/core/device/models.py @@ -13,6 +13,11 @@ from .utils import LANDING_PAGE_LEARN from .utils import LANDING_PAGE_SIGN_IN from kolibri.core.auth.constants import role_kinds +from kolibri.core.auth.constants.demographics import custom_demographics_schema +from kolibri.core.auth.constants.demographics import DescriptionTranslationValidator +from kolibri.core.auth.constants.demographics import EnumValuesValidator +from kolibri.core.auth.constants.demographics import LabelTranslationValidator +from kolibri.core.auth.constants.demographics import UniqueIdsValidator from kolibri.core.auth.models import AbstractFacilityDataModel from kolibri.core.auth.models import Facility from kolibri.core.auth.models import FacilityUser @@ -88,6 +93,9 @@ def app_is_enabled(): return interface.enabled +DEFAULT_DEMOGRAPHIC_FIELDS_KEY = "default_demographic_field_schema" + + # '"optional":True' is obsolete but needed while we keep using an # old json_schema_validator version compatible with python 2.7 extra_settings_schema = { @@ -98,6 +106,7 @@ def app_is_enabled(): "allow_learner_download_resources": {"type": "boolean", "optional": True}, "set_limit_for_autodownload": {"type": "boolean", "optional": True}, "limit_for_autodownload": {"type": "integer", "optional": True}, + DEFAULT_DEMOGRAPHIC_FIELDS_KEY: custom_demographics_schema, }, "required": [ "allow_download_on_metered_connection", @@ -155,7 +164,13 @@ class DeviceSettings(models.Model): extra_settings = JSONField( null=False, - validators=[JSON_Schema_Validator(extra_settings_schema)], + validators=[ + JSON_Schema_Validator(extra_settings_schema), + UniqueIdsValidator(DEFAULT_DEMOGRAPHIC_FIELDS_KEY), + DescriptionTranslationValidator(DEFAULT_DEMOGRAPHIC_FIELDS_KEY), + EnumValuesValidator(DEFAULT_DEMOGRAPHIC_FIELDS_KEY), + LabelTranslationValidator(DEFAULT_DEMOGRAPHIC_FIELDS_KEY), + ], default=extra_settings_default_values, ) @@ -194,29 +209,20 @@ def _get_extra(self, name): :return: mixed """ try: - return self.extra_settings.get(name, extra_settings_default_values[name]) + return self.extra_settings[name] except KeyError: - return extra_settings_default_values[name] - - @property - def allow_download_on_metered_connection(self): - return self._get_extra("allow_download_on_metered_connection") - - @property - def enable_automatic_download(self): - return self._get_extra("enable_automatic_download") - - @property - def allow_learner_download_resources(self): - return self._get_extra("allow_learner_download_resources") - - @property - def set_limit_for_autodownload(self): - return self._get_extra("set_limit_for_autodownload") - - @property - def limit_for_autodownload(self): - return self._get_extra("limit_for_autodownload") + return extra_settings_default_values.get(name) + + def __getattribute__(self, name): + if name in extra_settings_schema["properties"]: + return self._get_extra(name) + return super(DeviceSettings, self).__getattribute__(name) + + def __setattr__(self, name, value): + if name in extra_settings_schema["properties"]: + self.extra_settings[name] = value + else: + super(DeviceSettings, self).__setattr__(name, value) CONTENT_CACHE_KEY_CACHE_KEY = "content_cache_key" diff --git a/kolibri/core/device/utils.py b/kolibri/core/device/utils.py index 117373c4ac6..7209bd878e2 100644 --- a/kolibri/core/device/utils.py +++ b/kolibri/core/device/utils.py @@ -101,7 +101,6 @@ def set_device_settings(**kwargs): :param kwargs: a dictionary of key-value pairs to set on the device settings model """ from .models import DeviceSettings - from .models import extra_settings_schema try: device_settings = DeviceSettings.objects.get() @@ -111,16 +110,9 @@ def set_device_settings(**kwargs): language_id=settings.LANGUAGE_CODE ) - extra_settings_properties = extra_settings_schema.get("properties", {}).keys() - extra_settings = device_settings.extra_settings or {} - for key, value in kwargs.items(): - if key not in extra_settings_properties: - setattr(device_settings, key, value) - else: - extra_settings[key] = value + setattr(device_settings, key, value) - device_settings.extra_settings = extra_settings device_settings.save() diff --git a/kolibri/core/utils/validators.py b/kolibri/core/utils/validators.py index 450d6237b10..2368c392213 100644 --- a/kolibri/core/utils/validators.py +++ b/kolibri/core/utils/validators.py @@ -21,3 +21,45 @@ def __eq__(self, other): if not hasattr(other, "deconstruct"): return False return self.deconstruct() == other.deconstruct() + + +@deconstructible +class NoRepeatedValueJSONArrayValidator(object): + def __init__(self, array_key=None, object_key=None): + """ + A validator that checks that the values of the array are unique. + This assumes that the JSON is passed as a Python dictionary or list. + :param array_key: the key of the object that the array is stored at. + If None, then the array must be the passed value. + :param object_key: the comparison key for objects in the array. + Not needed for simple types. + """ + self.array_key = array_key + self.object_key = object_key + + def __call__(self, value): + value_to_check = value + if self.array_key: + value_to_check = value.get(self.array_key, []) + if not isinstance(value_to_check, list): + raise ValidationError( + "Value must be an array" + + (" ({})".format(self.array_key) if self.array_key else ""), + code="invalid", + ) + if self.object_key is not None: + values = [v[self.object_key] for v in value_to_check] + else: + values = value_to_check + if len(values) != len(set(values)): + raise ValidationError( + "Array must contain unique values" + + (" ({})".format(self.array_key) if self.array_key else ""), + code="invalid", + ) + return value + + def __eq__(self, other): + if not hasattr(other, "deconstruct"): + return False + return self.deconstruct() == other.deconstruct() diff --git a/kolibri/plugins/facility/assets/src/modules/userManagement/actions.js b/kolibri/plugins/facility/assets/src/modules/userManagement/actions.js index 460f3ad4c8e..c8f3e57b9ea 100644 --- a/kolibri/plugins/facility/assets/src/modules/userManagement/actions.js +++ b/kolibri/plugins/facility/assets/src/modules/userManagement/actions.js @@ -33,6 +33,7 @@ export function createFacilityUser(store, payload) { id_number: payload.id_number, gender: payload.gender, birth_year: payload.birth_year, + extra_demographics: payload.extra_demographics, }, }).then(facilityUser => { if (payload.role.kind !== UserKinds.LEARNER) { diff --git a/kolibri/plugins/facility/assets/src/views/UserCreatePage.vue b/kolibri/plugins/facility/assets/src/views/UserCreatePage.vue index f48c3fe6ad7..e4505656144 100644 --- a/kolibri/plugins/facility/assets/src/views/UserCreatePage.vue +++ b/kolibri/plugins/facility/assets/src/views/UserCreatePage.vue @@ -82,6 +82,12 @@ class="select" /> + +
@@ -122,6 +128,7 @@ import UsernameTextbox from 'kolibri.coreVue.components.UsernameTextbox'; import PasswordTextbox from 'kolibri.coreVue.components.PasswordTextbox'; import commonCoreStrings from 'kolibri.coreVue.mixins.commonCoreStrings'; + import ExtraDemographics from 'kolibri-common/components/ExtraDemographics'; import IdentifierTextbox from './IdentifierTextbox'; const { NOT_SPECIFIED } = DemographicConstants; @@ -141,6 +148,7 @@ PasswordTextbox, IdentifierTextbox, ImmersivePage, + ExtraDemographics, }, mixins: [commonCoreStrings], data() { @@ -153,6 +161,7 @@ passwordValid: false, gender: NOT_SPECIFIED, birthYear: NOT_SPECIFIED, + extraDemographics: {}, idNumber: '', loading: true, kind: { @@ -240,6 +249,7 @@ id_number: this.idNumber, gender: this.gender, birth_year: this.birthYear, + extra_demographics: this.extraDemographics, role: { kind: this.newUserRole, }, diff --git a/kolibri/plugins/facility/assets/src/views/UserEditPage.vue b/kolibri/plugins/facility/assets/src/views/UserEditPage.vue index 8c4be5d5d98..a787ddaff76 100644 --- a/kolibri/plugins/facility/assets/src/views/UserEditPage.vue +++ b/kolibri/plugins/facility/assets/src/views/UserEditPage.vue @@ -92,6 +92,13 @@ :disabled="formDisabled" class="select" /> + + +

@@ -129,7 +136,7 @@ import pickBy from 'lodash/pickBy'; import UserType from 'kolibri.utils.UserType'; import { FacilityUserResource } from 'kolibri.resources'; - import { mapState, mapGetters } from 'vuex'; + import { mapActions, mapState, mapGetters } from 'vuex'; import urls from 'kolibri.urls'; import { UserKinds, ERROR_CONSTANTS } from 'kolibri.coreVue.vuex.constants'; import CatchErrors from 'kolibri.utils.CatchErrors'; @@ -140,6 +147,7 @@ import FullNameTextbox from 'kolibri.coreVue.components.FullNameTextbox'; import UsernameTextbox from 'kolibri.coreVue.components.UsernameTextbox'; import commonCoreStrings from 'kolibri.coreVue.mixins.commonCoreStrings'; + import ExtraDemographics from 'kolibri-common/components/ExtraDemographics'; import IdentifierTextbox from './IdentifierTextbox'; export default { @@ -157,6 +165,7 @@ IdentifierTextbox, UsernameTextbox, UserTypeDisplay, + ExtraDemographics, }, mixins: [commonCoreStrings], data() { @@ -173,13 +182,14 @@ gender: '', birthYear: '', idNumber: '', + extraDemographics: null, userCopy: {}, caughtErrors: [], status: '', }; }, computed: { - ...mapGetters(['currentUserId']), + ...mapGetters(['currentUserId', 'facilityConfig']), ...mapState('userManagement', ['facilityUsers']), formDisabled() { return this.status === 'BUSY'; @@ -238,17 +248,21 @@ }, }, created() { - FacilityUserResource.fetchModel({ + const facilityConfigPromise = this.getFacilityConfig(); + const facilityUserPromise = FacilityUserResource.fetchModel({ id: this.$route.params.id, - }) - .then(user => { - this.username = user.username; - this.fullName = user.full_name; - this.idNumber = user.id_number; - this.gender = user.gender; - this.birthYear = user.birth_year; - this.setKind(user); - this.makeCopyOfUser(user); + }).then(user => { + this.username = user.username; + this.fullName = user.full_name; + this.idNumber = user.id_number; + this.gender = user.gender; + this.birthYear = user.birth_year; + this.extraDemographics = user.extra_demographics; + this.setKind(user); + this.makeCopyOfUser(user); + }); + Promise.all([facilityConfigPromise, facilityUserPromise]) + .then(() => { this.loading = false; }) .catch(error => { @@ -256,6 +270,7 @@ }); }, methods: { + ...mapActions(['getFacilityConfig']), setKind(user) { this.kind = UserType(user); const coachOption = this.userTypeOptions[1]; @@ -275,6 +290,7 @@ full_name: this.fullName, gender: this.gender, id_number: this.idNumber, + extra_demographics: this.extraDemographics, username: this.username, kind: UserType(user), }; @@ -300,6 +316,7 @@ full_name: this.fullName, gender: this.gender, id_number: this.idNumber, + extra_demographics: this.extraDemographics, username: this.username, }, (value, key) => { diff --git a/packages/kolibri-common/components/ExtraDemographics/ExtraDemographicField.vue b/packages/kolibri-common/components/ExtraDemographics/ExtraDemographicField.vue new file mode 100644 index 00000000000..aa1928f4617 --- /dev/null +++ b/packages/kolibri-common/components/ExtraDemographics/ExtraDemographicField.vue @@ -0,0 +1,65 @@ + + + + diff --git a/packages/kolibri-common/components/ExtraDemographics/__tests__/ExtraDemographicField.spec.js b/packages/kolibri-common/components/ExtraDemographics/__tests__/ExtraDemographicField.spec.js new file mode 100644 index 00000000000..e4d3a252d43 --- /dev/null +++ b/packages/kolibri-common/components/ExtraDemographics/__tests__/ExtraDemographicField.spec.js @@ -0,0 +1,157 @@ +import { mount } from '@vue/test-utils'; +import ExtraDemographicField from '../ExtraDemographicField.vue'; + +const label = 'Age'; + +const field = { + id: 'age', + description: label, + enumValues: [ + { + value: '0-5', + defaultLabel: 'Zero to Five', + }, + { + value: '6-10', + defaultLabel: 'Six to Ten', + }, + { + value: '11-15', + defaultLabel: 'Eleven to Fifteen', + }, + { + value: '16-20', + defaultLabel: 'Sixteen to Twenty', + }, + { + value: '21-25', + defaultLabel: 'Twenty-One to Twenty-Five', + }, + ], +}; + +describe('ExtraDemographicField', () => { + it('renders the label correctly', () => { + const wrapper = mount(ExtraDemographicField, { + propsData: { + field, + value: '0-5', + }, + }); + expect(wrapper.vm.description).toBe(label); + }); + + it('renders the translated label correctly', () => { + const wrapper = mount(ExtraDemographicField, { + propsData: { + field: { + ...field, + translations: { + en: 'Not Age', + }, + }, + value: '0-5', + }, + }); + expect(wrapper.vm.description).toBe('Not Age'); + }); + + it('generates the correct options', () => { + const wrapper = mount(ExtraDemographicField, { + propsData: { + field, + value: '0-5', + }, + }); + + const options = wrapper.vm.options; + expect(options.length).toBe(5); + expect(options[0].value).toBe('0-5'); + expect(options[0].label).toBe('Zero to Five'); + expect(options[1].value).toBe('6-10'); + expect(options[1].label).toBe('Six to Ten'); + expect(options[2].value).toBe('11-15'); + expect(options[2].label).toBe('Eleven to Fifteen'); + expect(options[3].value).toBe('16-20'); + expect(options[3].label).toBe('Sixteen to Twenty'); + expect(options[4].value).toBe('21-25'); + expect(options[4].label).toBe('Twenty-One to Twenty-Five'); + }); + + it('generates the correct translated options', () => { + const enumValues = [ + { + value: '0-5', + defaultLabel: 'Zero to Five', + translations: { + en: 'Less than Five', + }, + }, + { + value: '6-10', + defaultLabel: 'Six to Ten', + translations: { + en: 'More than Six but less than Ten', + }, + }, + { + value: '11-15', + defaultLabel: 'Eleven to Fifteen', + translations: { + en: 'More than Eleven but less than Fifteen', + }, + }, + { + value: '16-20', + defaultLabel: 'Sixteen to Twenty', + translations: { + en: 'More than Sixteen but less than Twenty', + }, + }, + { + value: '21-25', + defaultLabel: 'Twenty-One to Twenty-Five', + translations: { + en: 'More than Twenty-One but less than Twenty-Five', + }, + }, + ]; + const wrapper = mount(ExtraDemographicField, { + propsData: { + field: { + ...field, + enumValues, + }, + value: '0-5', + }, + }); + + const options = wrapper.vm.options; + expect(options.length).toBe(5); + expect(options[0].value).toBe('0-5'); + expect(options[0].label).toBe('Less than Five'); + expect(options[1].value).toBe('6-10'); + expect(options[1].label).toBe('More than Six but less than Ten'); + expect(options[2].value).toBe('11-15'); + expect(options[2].label).toBe('More than Eleven but less than Fifteen'); + expect(options[3].value).toBe('16-20'); + expect(options[3].label).toBe('More than Sixteen but less than Twenty'); + expect(options[4].value).toBe('21-25'); + expect(options[4].label).toBe('More than Twenty-One but less than Twenty-Five'); + }); + + it('emits the select event when the value changes', () => { + const wrapper = mount(ExtraDemographicField, { + propsData: { + field, + value: '0-5', + }, + }); + + const input = wrapper.findComponent({ name: 'KSelect' }); + input.vm.$emit('select', { value: '6-10' }); + + expect(wrapper.emitted().select).toBeTruthy(); + expect(wrapper.emitted().select[0]).toEqual(['6-10']); + }); +}); diff --git a/packages/kolibri-common/components/ExtraDemographics/__tests__/ExtraDemographics.spec.js b/packages/kolibri-common/components/ExtraDemographics/__tests__/ExtraDemographics.spec.js new file mode 100644 index 00000000000..ed6cb9bce07 --- /dev/null +++ b/packages/kolibri-common/components/ExtraDemographics/__tests__/ExtraDemographics.spec.js @@ -0,0 +1,102 @@ +import { mount } from '@vue/test-utils'; +import ExtraDemographics from '../index.vue'; +import ExtraDemographicField from '../ExtraDemographicField.vue'; + +const field1 = { + id: 'age', + description: 'Age', + enumValues: [ + { + value: '0-5', + defaultLabel: 'Zero to Five', + }, + { + value: '6-10', + defaultLabel: 'Six to Ten', + }, + { + value: '11-15', + defaultLabel: 'Eleven to Fifteen', + }, + { + value: '16-20', + defaultLabel: 'Sixteen to Twenty', + }, + { + value: '21-25', + defaultLabel: 'Twenty-One to Twenty-Five', + }, + ], +}; + +const field2 = { + id: 'flavour', + description: 'Flavour', + enumValues: [ + { + value: 'up', + defaultLabel: 'Up', + }, + { + value: 'down', + defaultLabel: 'Down', + }, + { + value: 'strange', + defaultLabel: 'Strange', + }, + { + value: 'charm', + defaultLabel: 'Charm', + }, + { + value: 'top', + defaultLabel: 'Top', + }, + { + value: 'bottom', + defaultLabel: 'Bottom', + }, + ], +}; + +const mockFacilityDatasetExtraFields = { + demographic_fields: [field1, field2], +}; + +describe('ExtraDemographics', () => { + it('renders ExtraDemographicField for each field in customSchema', () => { + const wrapper = mount(ExtraDemographics, { + propsData: { + facilityDatasetExtraFields: mockFacilityDatasetExtraFields, + value: null, + }, + }); + const extraDemographicFields = wrapper.findAllComponents(ExtraDemographicField); + expect(extraDemographicFields).toHaveLength( + mockFacilityDatasetExtraFields.demographic_fields.length + ); + }); + + it('emits input event with updated value when ExtraDemographicField emits select', () => { + const wrapper = mount(ExtraDemographics, { + propsData: { + facilityDatasetExtraFields: mockFacilityDatasetExtraFields, + value: null, + }, + }); + const extraDemographicField = wrapper.findComponent(ExtraDemographicField); + extraDemographicField.vm.$emit('select', '6-10'); + expect(wrapper.emitted().input[0]).toEqual([{ age: '6-10' }]); + }); + + it('computes customSchema correctly', () => { + const wrapper = mount(ExtraDemographics, { + propsData: { + facilityDatasetExtraFields: mockFacilityDatasetExtraFields, + value: null, + }, + }); + expect(wrapper.vm.customSchema).toEqual(mockFacilityDatasetExtraFields.demographic_fields); + }); +}); diff --git a/packages/kolibri-common/components/ExtraDemographics/index.vue b/packages/kolibri-common/components/ExtraDemographics/index.vue new file mode 100644 index 00000000000..16aa6a78642 --- /dev/null +++ b/packages/kolibri-common/components/ExtraDemographics/index.vue @@ -0,0 +1,57 @@ + + + +