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
|
||||
|
||||
|
||||
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)
|
||||
|
@ -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")
|
||||
|
@ -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:
|
||||
|
Loading…
x
Reference in New Issue
Block a user