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"
/>
+