Backend for custom fields in dashboard views
This commit is contained in:
parent
cd0f5d8286
commit
cbea10fb24
@ -393,6 +393,164 @@ class Log(models.Model):
|
|||||||
return self.message
|
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 SavedView(ModelWithOwner):
|
||||||
class DashboardViewDisplayMode(models.TextChoices):
|
class DashboardViewDisplayMode(models.TextChoices):
|
||||||
TABLE = ("table", _("Table"))
|
TABLE = ("table", _("Table"))
|
||||||
@ -407,6 +565,9 @@ class SavedView(ModelWithOwner):
|
|||||||
CORRESPONDENT = ("correspondent", _("Correspondent"))
|
CORRESPONDENT = ("correspondent", _("Correspondent"))
|
||||||
STORAGE_PATH = ("storagepath", _("Storage Path"))
|
STORAGE_PATH = ("storagepath", _("Storage Path"))
|
||||||
|
|
||||||
|
class DashboardViewDynamicTableColumns:
|
||||||
|
CUSTOM_FIELD = ("custom_field_%d", CustomField)
|
||||||
|
|
||||||
name = models.CharField(_("name"), max_length=128)
|
name = models.CharField(_("name"), max_length=128)
|
||||||
|
|
||||||
show_on_dashboard = models.BooleanField(
|
show_on_dashboard = models.BooleanField(
|
||||||
@ -437,10 +598,11 @@ class SavedView(ModelWithOwner):
|
|||||||
default=DashboardViewDisplayMode.TABLE,
|
default=DashboardViewDisplayMode.TABLE,
|
||||||
)
|
)
|
||||||
|
|
||||||
dashboard_view_table_columns = MultiSelectField(
|
dashboard_view_table_columns = DynamicMultiSelectField(
|
||||||
max_length=128,
|
max_length=128,
|
||||||
verbose_name=_("Dashboard view table display columns"),
|
verbose_name=_("Dashboard view table display columns"),
|
||||||
choices=DashboardViewTableColumns.choices,
|
choices=DashboardViewTableColumns.choices,
|
||||||
|
dyanmic_choices=[DashboardViewDynamicTableColumns.CUSTOM_FIELD],
|
||||||
default=f"{DashboardViewTableColumns.CREATED},{DashboardViewTableColumns.TITLE},{DashboardViewTableColumns.TAGS},{DashboardViewTableColumns.CORRESPONDENT}",
|
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}"
|
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:
|
if settings.AUDIT_LOG_ENABLED:
|
||||||
auditlog.register(Document, m2m_fields={"tags"})
|
auditlog.register(Document, m2m_fields={"tags"})
|
||||||
auditlog.register(Correspondent)
|
auditlog.register(Correspondent)
|
||||||
|
@ -797,10 +797,50 @@ class SavedViewFilterRuleSerializer(serializers.ModelSerializer):
|
|||||||
fields = ["rule_type", "value"]
|
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):
|
class SavedViewSerializer(OwnedObjectSerializer):
|
||||||
filter_rules = SavedViewFilterRuleSerializer(many=True)
|
filter_rules = SavedViewFilterRuleSerializer(many=True)
|
||||||
dashboard_view_table_columns = fields.MultipleChoiceField(
|
dashboard_view_table_columns = DynamicOrderedMultipleChoiceField(
|
||||||
choices=SavedView.DashboardViewTableColumns.choices,
|
choices=SavedView.DashboardViewTableColumns.choices,
|
||||||
|
dyanmic_choices=[("custom_field_%d", CustomField)],
|
||||||
required=False,
|
required=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -823,22 +863,6 @@ class SavedViewSerializer(OwnedObjectSerializer):
|
|||||||
"set_permissions",
|
"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):
|
def update(self, instance, validated_data):
|
||||||
if "filter_rules" in validated_data:
|
if "filter_rules" in validated_data:
|
||||||
rules_data = validated_data.pop("filter_rules")
|
rules_data = validated_data.pop("filter_rules")
|
||||||
|
@ -1579,6 +1579,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
|||||||
},
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
v1.refresh_from_db()
|
v1.refresh_from_db()
|
||||||
self.assertEqual(
|
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):
|
def test_get_logs(self):
|
||||||
log_data = "test\ntest2\n"
|
log_data = "test\ntest2\n"
|
||||||
with open(os.path.join(settings.LOGGING_DIR, "mail.log"), "w") as f:
|
with open(os.path.join(settings.LOGGING_DIR, "mail.log"), "w") as f:
|
||||||
|
Loading…
x
Reference in New Issue
Block a user