Backend for custom fields in dashboard views

This commit is contained in:
shamoon 2024-04-17 12:09:09 -07:00
parent cd0f5d8286
commit cbea10fb24
3 changed files with 269 additions and 151 deletions

View File

@ -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)

View File

@ -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")

View File

@ -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: