From cbea10fb24e12fab11fb4e9aac49aab6647a935f Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Wed, 17 Apr 2024 12:09:09 -0700 Subject: [PATCH] Backend for custom fields in dashboard views --- src/documents/models.py | 297 ++++++++++++---------- src/documents/serialisers.py | 58 +++-- src/documents/tests/test_api_documents.py | 65 +++++ 3 files changed, 269 insertions(+), 151 deletions(-) diff --git a/src/documents/models.py b/src/documents/models.py index dd97333dd..ab508675a 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -393,6 +393,164 @@ class Log(models.Model): return self.message +class CustomField(models.Model): + """ + Defines the name and type of a custom field + """ + + class FieldDataType(models.TextChoices): + STRING = ("string", _("String")) + URL = ("url", _("URL")) + DATE = ("date", _("Date")) + BOOL = ("boolean"), _("Boolean") + INT = ("integer", _("Integer")) + FLOAT = ("float", _("Float")) + MONETARY = ("monetary", _("Monetary")) + DOCUMENTLINK = ("documentlink", _("Document Link")) + + created = models.DateTimeField( + _("created"), + default=timezone.now, + db_index=True, + editable=False, + ) + + name = models.CharField(max_length=128) + + data_type = models.CharField( + _("data type"), + max_length=50, + choices=FieldDataType.choices, + editable=False, + ) + + class Meta: + ordering = ("created",) + verbose_name = _("custom field") + verbose_name_plural = _("custom fields") + constraints = [ + models.UniqueConstraint( + fields=["name"], + name="%(app_label)s_%(class)s_unique_name", + ), + ] + + def __str__(self) -> str: + return f"{self.name} : {self.data_type}" + + +class CustomFieldInstance(models.Model): + """ + A single instance of a field, attached to a CustomField for the name and type + and attached to a single Document to be metadata for it + """ + + created = models.DateTimeField( + _("created"), + default=timezone.now, + db_index=True, + editable=False, + ) + + document = models.ForeignKey( + Document, + blank=False, + null=False, + on_delete=models.CASCADE, + related_name="custom_fields", + editable=False, + ) + + field = models.ForeignKey( + CustomField, + blank=False, + null=False, + on_delete=models.CASCADE, + related_name="fields", + editable=False, + ) + + # Actual data storage + value_text = models.CharField(max_length=128, null=True) + + value_bool = models.BooleanField(null=True) + + value_url = models.URLField(null=True) + + value_date = models.DateField(null=True) + + value_int = models.IntegerField(null=True) + + value_float = models.FloatField(null=True) + + value_monetary = models.CharField(null=True, max_length=128) + + value_document_ids = models.JSONField(null=True) + + class Meta: + ordering = ("created",) + verbose_name = _("custom field instance") + verbose_name_plural = _("custom field instances") + constraints = [ + models.UniqueConstraint( + fields=["document", "field"], + name="%(app_label)s_%(class)s_unique_document_field", + ), + ] + + def __str__(self) -> str: + return str(self.field.name) + f" : {self.value}" + + @property + def value(self): + """ + Based on the data type, access the actual value the instance stores + A little shorthand/quick way to get what is actually here + """ + if self.field.data_type == CustomField.FieldDataType.STRING: + return self.value_text + elif self.field.data_type == CustomField.FieldDataType.URL: + return self.value_url + elif self.field.data_type == CustomField.FieldDataType.DATE: + return self.value_date + elif self.field.data_type == CustomField.FieldDataType.BOOL: + return self.value_bool + elif self.field.data_type == CustomField.FieldDataType.INT: + return self.value_int + elif self.field.data_type == CustomField.FieldDataType.FLOAT: + return self.value_float + elif self.field.data_type == CustomField.FieldDataType.MONETARY: + return self.value_monetary + elif self.field.data_type == CustomField.FieldDataType.DOCUMENTLINK: + return self.value_document_ids + raise NotImplementedError(self.field.data_type) + + +class DynamicMultiSelectField(MultiSelectField): + """ + A MultiSelectField that can have dynamic choices from a model + """ + + def __init__(self, *args, **kwargs): + self.dynamic_choices = kwargs.pop("dyanmic_choices", None) + super().__init__(*args, **kwargs) + + def _get_choices(self): + return self._choices + + def _set_choices(self, value): + if self.dynamic_choices: + for key, model in self.dynamic_choices: + try: + for obj in model.objects.all(): + value.append((key % obj.pk, obj.name)) + except Exception: + pass + self._choices = value + + choices = property(_get_choices, _set_choices) + + class SavedView(ModelWithOwner): class DashboardViewDisplayMode(models.TextChoices): TABLE = ("table", _("Table")) @@ -407,6 +565,9 @@ class SavedView(ModelWithOwner): CORRESPONDENT = ("correspondent", _("Correspondent")) STORAGE_PATH = ("storagepath", _("Storage Path")) + class DashboardViewDynamicTableColumns: + CUSTOM_FIELD = ("custom_field_%d", CustomField) + name = models.CharField(_("name"), max_length=128) show_on_dashboard = models.BooleanField( @@ -437,10 +598,11 @@ class SavedView(ModelWithOwner): default=DashboardViewDisplayMode.TABLE, ) - dashboard_view_table_columns = MultiSelectField( + dashboard_view_table_columns = DynamicMultiSelectField( max_length=128, verbose_name=_("Dashboard view table display columns"), choices=DashboardViewTableColumns.choices, + dyanmic_choices=[DashboardViewDynamicTableColumns.CUSTOM_FIELD], default=f"{DashboardViewTableColumns.CREATED},{DashboardViewTableColumns.TITLE},{DashboardViewTableColumns.TAGS},{DashboardViewTableColumns.CORRESPONDENT}", ) @@ -781,139 +943,6 @@ class ShareLink(models.Model): return f"Share Link for {self.document.title}" -class CustomField(models.Model): - """ - Defines the name and type of a custom field - """ - - class FieldDataType(models.TextChoices): - STRING = ("string", _("String")) - URL = ("url", _("URL")) - DATE = ("date", _("Date")) - BOOL = ("boolean"), _("Boolean") - INT = ("integer", _("Integer")) - FLOAT = ("float", _("Float")) - MONETARY = ("monetary", _("Monetary")) - DOCUMENTLINK = ("documentlink", _("Document Link")) - - created = models.DateTimeField( - _("created"), - default=timezone.now, - db_index=True, - editable=False, - ) - - name = models.CharField(max_length=128) - - data_type = models.CharField( - _("data type"), - max_length=50, - choices=FieldDataType.choices, - editable=False, - ) - - class Meta: - ordering = ("created",) - verbose_name = _("custom field") - verbose_name_plural = _("custom fields") - constraints = [ - models.UniqueConstraint( - fields=["name"], - name="%(app_label)s_%(class)s_unique_name", - ), - ] - - def __str__(self) -> str: - return f"{self.name} : {self.data_type}" - - -class CustomFieldInstance(models.Model): - """ - A single instance of a field, attached to a CustomField for the name and type - and attached to a single Document to be metadata for it - """ - - created = models.DateTimeField( - _("created"), - default=timezone.now, - db_index=True, - editable=False, - ) - - document = models.ForeignKey( - Document, - blank=False, - null=False, - on_delete=models.CASCADE, - related_name="custom_fields", - editable=False, - ) - - field = models.ForeignKey( - CustomField, - blank=False, - null=False, - on_delete=models.CASCADE, - related_name="fields", - editable=False, - ) - - # Actual data storage - value_text = models.CharField(max_length=128, null=True) - - value_bool = models.BooleanField(null=True) - - value_url = models.URLField(null=True) - - value_date = models.DateField(null=True) - - value_int = models.IntegerField(null=True) - - value_float = models.FloatField(null=True) - - value_monetary = models.CharField(null=True, max_length=128) - - value_document_ids = models.JSONField(null=True) - - class Meta: - ordering = ("created",) - verbose_name = _("custom field instance") - verbose_name_plural = _("custom field instances") - constraints = [ - models.UniqueConstraint( - fields=["document", "field"], - name="%(app_label)s_%(class)s_unique_document_field", - ), - ] - - def __str__(self) -> str: - return str(self.field.name) + f" : {self.value}" - - @property - def value(self): - """ - Based on the data type, access the actual value the instance stores - A little shorthand/quick way to get what is actually here - """ - if self.field.data_type == CustomField.FieldDataType.STRING: - return self.value_text - elif self.field.data_type == CustomField.FieldDataType.URL: - return self.value_url - elif self.field.data_type == CustomField.FieldDataType.DATE: - return self.value_date - elif self.field.data_type == CustomField.FieldDataType.BOOL: - return self.value_bool - elif self.field.data_type == CustomField.FieldDataType.INT: - return self.value_int - elif self.field.data_type == CustomField.FieldDataType.FLOAT: - return self.value_float - elif self.field.data_type == CustomField.FieldDataType.MONETARY: - return self.value_monetary - elif self.field.data_type == CustomField.FieldDataType.DOCUMENTLINK: - return self.value_document_ids - raise NotImplementedError(self.field.data_type) - - if settings.AUDIT_LOG_ENABLED: auditlog.register(Document, m2m_fields={"tags"}) auditlog.register(Correspondent) diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 46c1373fc..4a8852d2e 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -797,10 +797,50 @@ class SavedViewFilterRuleSerializer(serializers.ModelSerializer): fields = ["rule_type", "value"] +class DynamicOrderedMultipleChoiceField(fields.MultipleChoiceField): + """ + A MultipleChoiceField that allows for dynamic choices from a model + and preserves the order of the choices. + """ + + def __init__(self, **kwargs): + self.dyanmic_choices = kwargs.pop("dyanmic_choices", None) + super().__init__(**kwargs) + + def _get_choices(self): + return super()._get_choices() + + def _set_choices(self, choices): + if self.dyanmic_choices is not None: + for key, Model in self.dyanmic_choices: + try: + for obj in Model.objects.all(): + choices.append((key % obj.pk, obj.name)) + except Exception: + pass + return super()._set_choices(choices) + + choices = property(_get_choices, _set_choices) + + def to_internal_value(self, data): + # MultipleChoiceField doesn't preserve order, so we use an array + if isinstance(data, str) or not hasattr(data, "__iter__"): + self.fail("not_a_list", input_type=type(data).__name__) + if not self.allow_empty and len(data) == 0: + self.fail("empty") + + return [fields.ChoiceField.to_internal_value(self, item) for item in data] + + def to_representation(self, value): + # MultipleChoiceField doesn't preserve order, so we return as array to match the original order + return [self.choice_strings_to_values.get(str(item), item) for item in value] + + class SavedViewSerializer(OwnedObjectSerializer): filter_rules = SavedViewFilterRuleSerializer(many=True) - dashboard_view_table_columns = fields.MultipleChoiceField( + dashboard_view_table_columns = DynamicOrderedMultipleChoiceField( choices=SavedView.DashboardViewTableColumns.choices, + dyanmic_choices=[("custom_field_%d", CustomField)], required=False, ) @@ -823,22 +863,6 @@ class SavedViewSerializer(OwnedObjectSerializer): "set_permissions", ] - def to_internal_value(self, data): - value = super().to_internal_value(data) - # MultipleChoiceField doesn't preserve order, so we revert the dict to the original array - if "dashboard_view_table_columns" in data: - value["dashboard_view_table_columns"] = data["dashboard_view_table_columns"] - return value - - def to_representation(self, instance): - representation = super().to_representation(instance) - # MultipleChoiceField doesn't preserve order, so we re-order the array to match the original order - if "dashboard_view_table_columns" in representation: - representation["dashboard_view_table_columns"] = [ - str(x) for x in instance.dashboard_view_table_columns - ] - return representation - def update(self, instance, validated_data): if "filter_rules" in validated_data: rules_data = validated_data.pop("filter_rules") diff --git a/src/documents/tests/test_api_documents.py b/src/documents/tests/test_api_documents.py index e2e961d13..e057cb174 100644 --- a/src/documents/tests/test_api_documents.py +++ b/src/documents/tests/test_api_documents.py @@ -1579,6 +1579,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): }, format="json", ) + self.assertEqual(response.status_code, status.HTTP_200_OK) v1.refresh_from_db() self.assertEqual( @@ -1590,6 +1591,70 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): ], ) + def test_saved_view_dashboard_view_customfields(self): + view = { + "name": "test", + "show_on_dashboard": True, + "show_in_sidebar": True, + "sort_field": "created2", + "filter_rules": [{"rule_type": 4, "value": "test"}], + "dashboard_view_limit": 20, + "dashboard_view_mode": SavedView.DashboardViewDisplayMode.SMALL_CARDS, + "dashboard_view_table_columns": [ + SavedView.DashboardViewTableColumns.TITLE, + SavedView.DashboardViewTableColumns.CREATED, + ], + } + + response = self.client.post("/api/saved_views/", view, format="json") + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + v1 = SavedView.objects.get(name="test") + + custom_field = CustomField.objects.create( + name="stringfield", + data_type=CustomField.FieldDataType.STRING, + ) + + response = self.client.patch( + f"/api/saved_views/{v1.id}/", + { + "dashboard_view_table_columns": [ + SavedView.DashboardViewTableColumns.TITLE, + SavedView.DashboardViewTableColumns.CREATED, + SavedView.DashboardViewDynamicTableColumns.CUSTOM_FIELD[0] + % custom_field.id, + ], + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + v1.refresh_from_db() + self.assertEqual( + v1.dashboard_view_table_columns, + [ + str(SavedView.DashboardViewTableColumns.TITLE), + str(SavedView.DashboardViewTableColumns.CREATED), + SavedView.DashboardViewDynamicTableColumns.CUSTOM_FIELD[0] + % custom_field.id, + ], + ) + + # Custom field not found + response = self.client.patch( + f"/api/saved_views/{v1.id}/", + { + "dashboard_view_table_columns": [ + SavedView.DashboardViewTableColumns.TITLE, + SavedView.DashboardViewTableColumns.CREATED, + SavedView.DashboardViewDynamicTableColumns.CUSTOM_FIELD[0] % 99, + ], + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + def test_get_logs(self): log_data = "test\ntest2\n" with open(os.path.join(settings.LOGGING_DIR, "mail.log"), "w") as f: