1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-17 18:32:35 +00:00

Merge branch '1836-table-collection-element-02-models' into 'develop'

[backend] Add table element

See merge request 
This commit is contained in:
Jérémie Pardou 2023-09-22 13:01:54 +00:00
commit afd26c5a82
14 changed files with 551 additions and 16 deletions

View file

@ -18,6 +18,8 @@ lint-python: lint
format:
black . ../premium/backend ../enterprise/backend --extend-exclude='/generated/' || exit;
fix: sort format
sort:
isort --skip generated src tests ../premium/backend ../enterprise/backend || exit;

View file

@ -4,7 +4,7 @@ from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from baserow.contrib.builder.elements.models import Element
from baserow.contrib.builder.elements.models import CollectionElementField, Element
from baserow.contrib.builder.elements.registries import element_type_registry
from baserow.core.formula.serializers import FormulaSerializerField
@ -112,3 +112,15 @@ class MoveElementSerializer(serializers.Serializer):
class PageParameterValueSerializer(serializers.Serializer):
name = serializers.CharField()
value = FormulaSerializerField(allow_blank=True)
class CollectionElementFieldSerializer(serializers.ModelSerializer):
value = FormulaSerializerField(allow_blank=True)
class Meta:
model = CollectionElementField
fields = (
"id",
"name",
"value",
)

View file

@ -287,6 +287,9 @@ class BuilderApplicationType(ApplicationType):
if "builder_page_elements" not in id_mapping:
id_mapping["builder_page_elements"] = {}
if "builder_data_sources" not in id_mapping:
id_mapping["builder_data_sources"] = {}
if "workspace_id" not in id_mapping and builder.workspace is not None:
id_mapping["workspace_id"] = builder.workspace.id
@ -309,17 +312,6 @@ class BuilderApplicationType(ApplicationType):
imported_pages.append(page_instance)
progress.increment(state=IMPORT_SERIALIZED_IMPORTING)
# Then we create all the element instances.
for serialized_page in serialized_pages:
for serialized_element in serialized_page["elements"]:
self.import_element(
serialized_element,
serialized_page,
id_mapping,
)
progress.increment(state=IMPORT_SERIALIZED_IMPORTING)
# Then we create all the datasource instances.
for serialized_page in serialized_pages:
for serialized_data_source in serialized_page["data_sources"]:
@ -349,10 +341,25 @@ class BuilderApplicationType(ApplicationType):
name=serialized_data_source["name"],
)
id_mapping["builder_data_sources"][
serialized_data_source["id"]
] = data_source.id
serialized_page["_data_source_objects"].append(data_source)
progress.increment(state=IMPORT_SERIALIZED_IMPORTING)
# Then we create all the element instances.
for serialized_page in serialized_pages:
for serialized_element in serialized_page["elements"]:
self.import_element(
serialized_element,
serialized_page,
id_mapping,
)
progress.increment(state=IMPORT_SERIALIZED_IMPORTING)
return imported_pages
def import_element(

View file

@ -142,6 +142,7 @@ class BuilderConfig(AppConfig):
InputTextElementType,
LinkElementType,
ParagraphElementType,
TableElementType,
)
from .elements.registries import element_type_registry
@ -152,6 +153,7 @@ class BuilderConfig(AppConfig):
element_type_registry.register(InputTextElementType())
element_type_registry.register(ColumnElementType())
element_type_registry.register(ButtonElementType())
element_type_registry.register(TableElementType())
from .domains.domain_types import CustomDomainType, SubDomainType
from .domains.registries import domain_type_registry

View file

@ -8,10 +8,12 @@ from django.db.models.functions import Cast
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from baserow.contrib.builder.data_sources.handler import DataSourceHandler
from baserow.contrib.builder.elements.handler import ElementHandler
from baserow.contrib.builder.elements.models import (
WIDTHS,
ButtonElement,
CollectionElementField,
ColumnElement,
ContainerElement,
Element,
@ -21,6 +23,7 @@ from baserow.contrib.builder.elements.models import (
InputTextElement,
LinkElement,
ParagraphElement,
TableElement,
VerticalAlignments,
)
from baserow.contrib.builder.elements.registries import ElementType
@ -101,6 +104,110 @@ class ContainerElementType(ElementType, ABC):
pass
class CollectionElementType(ElementType, ABC):
allowed_fields = ["data_source", "data_source_id"]
serializer_field_names = ["data_source_id", "fields"]
class SerializedDict(ElementDict):
data_source_id: int
fields: List[Dict]
@property
def serializer_field_overrides(self):
from baserow.contrib.builder.api.elements.serializers import (
CollectionElementFieldSerializer,
)
overrides = {
"data_source_id": serializers.IntegerField(
allow_null=True,
default=None,
help_text=TableElement._meta.get_field("data_source").help_text,
required=False,
),
"fields": CollectionElementFieldSerializer(
many=True, required=False, help_text="The fields to show in the table."
),
}
return overrides
def prepare_value_for_db(
self, values: Dict, instance: Optional[LinkElement] = None
):
if "data_source_id" in values:
data_source_id = values.pop("data_source_id")
if data_source_id is not None:
data_source = DataSourceHandler().get_data_source(data_source_id)
if (
not data_source.service
or not data_source.service.specific.get_type().returns_list
):
raise ValidationError(
f"The data source with ID {data_source_id} doesn't return a "
"list."
)
values["data_source"] = data_source
else:
values["data_source"] = None
return super().prepare_value_for_db(values, instance)
def after_create(self, instance, values):
if "fields" in values:
created_fields = CollectionElementField.objects.bulk_create(
[
CollectionElementField(**field, order=index)
for index, field in enumerate(values["fields"])
]
)
instance.fields.add(*created_fields)
def after_update(self, instance, values):
if "fields" in values:
# Remove previous fields
instance.fields.clear()
self.after_create(instance, values)
def before_delete(self, instance):
instance.fields.all().delete()
def get_property_for_serialization(self, element: Element, prop_name: str):
"""
You can customize the behavior of the serialization of a property with this
hook.
"""
if prop_name == "fields":
return [{"name": f.name, "value": f.value} for f in element.fields.all()]
return super().get_property_for_serialization(element, prop_name)
def import_serialized(self, page, serialized_values, id_mapping):
serialized_copy = serialized_values.copy()
if serialized_copy["data_source_id"]:
serialized_copy["data_source_id"] = id_mapping["builder_data_sources"][
serialized_copy["data_source_id"]
]
fields = serialized_copy.pop("fields", [])
instance = super().import_serialized(page, serialized_copy, id_mapping)
# Create fields
created_fields = CollectionElementField.objects.bulk_create(
[
CollectionElementField(**field, order=index)
for index, field in enumerate(fields)
]
)
instance.fields.add(*created_fields)
return instance
class ColumnElementType(ContainerElementType):
"""
A column element is a container element that can be used to display other elements
@ -290,7 +397,7 @@ class LinkElementType(ElementType):
class SerializedDict(ElementDict):
value: BaserowFormula
navigation_type: str
navigate_to_page_id: Page
navigate_to_page_id: int
page_parameters: List
navigate_to_url: BaserowFormula
variant: str
@ -586,3 +693,11 @@ class ButtonElementType(ElementType):
def get_sample_params(self) -> Dict[str, Any]:
return {"value": "Some value"}
class TableElementType(CollectionElementType):
type = "table"
model_class = TableElement
def get_sample_params(self) -> Dict[str, Any]:
return {"data_source_id": None}

View file

@ -150,6 +150,8 @@ class ElementHandler:
element = model_class(page=page, order=order, **allowed_values)
element.save()
element_type.after_create(element, kwargs)
return element
def delete_element(self, element: Element):
@ -159,6 +161,8 @@ class ElementHandler:
:param element: The to-be-deleted element.
"""
element.get_type().before_delete(element)
element.delete()
def update_element(self, element: ElementForUpdate, **kwargs) -> Element:
@ -184,6 +188,8 @@ class ElementHandler:
element.save()
element.get_type().after_update(element, kwargs)
return element
def move_element(

View file

@ -3,7 +3,7 @@ from typing import Optional
from django.contrib.contenttypes.models import ContentType
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import QuerySet
from django.db.models import SET_NULL, QuerySet
from baserow.contrib.builder.pages.models import Page
from baserow.core.formula.field import FormulaField
@ -414,3 +414,42 @@ class ButtonElement(Element):
max_length=10,
default=HorizontalAlignments.LEFT,
)
class CollectionElementField(models.Model):
"""
A field of a Collection element
"""
order = models.PositiveIntegerField()
name = models.CharField(
max_length=225,
help_text="The name of the field.",
)
value = FormulaField(default="", help_text="The value of the field.")
class Meta:
ordering = ("order", "id")
class CollectionElement(Element):
data_source = models.ForeignKey(
"builder.DataSource",
null=True,
on_delete=SET_NULL,
help_text="The data source we want to show in the element for. "
"Only data_sources that return list are allowed.",
)
fields = models.ManyToManyField(
CollectionElementField, help_text="Fields of the collection element."
)
class Meta:
abstract = True
class TableElement(CollectionElement):
"""
A table element
"""

View file

@ -57,6 +57,31 @@ class ElementType(
return values
def after_create(self, instance: ElementSubClass, values: Dict):
"""
This hook is called right after the element has been created.
:param instance: The created element instance.
:param values: The values that were passed when creating the field
instance.
"""
def after_update(self, instance: ElementSubClass, values: Dict):
"""
This hook is called right after the element has been updated.
:param instance: The updated element instance.
:param values: The values that were passed when creating the field
instance.
"""
def before_delete(self, instance: ElementSubClass):
"""
This hook is called just before the element will be deleted.
:param instance: The to be deleted element instance.
"""
def get_property_for_serialization(self, element: Element, prop_name: str):
"""
You can customize the behavior of the serialization of a property with this

View file

@ -0,0 +1,81 @@
# Generated by Django 3.2.21 on 2023-09-22 12:04
import django.db.models.deletion
from django.db import migrations, models
import baserow.core.formula.field
class Migration(migrations.Migration):
dependencies = [
("builder", "0021_alter_domain_content_type"),
]
operations = [
migrations.CreateModel(
name="CollectionElementField",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("order", models.PositiveIntegerField()),
(
"name",
models.CharField(
help_text="The name of the field.", max_length=225
),
),
(
"value",
baserow.core.formula.field.FormulaField(
default="", help_text="The value of the field."
),
),
],
options={
"ordering": ("order", "id"),
},
),
migrations.CreateModel(
name="TableElement",
fields=[
(
"element_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="builder.element",
),
),
(
"data_source",
models.ForeignKey(
help_text="The data source we want to show in the element for. Only data_sources that return list are allowed.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="builder.datasource",
),
),
(
"fields",
models.ManyToManyField(
help_text="Fields of the collection element.",
to="builder.CollectionElementField",
),
),
],
options={
"abstract": False,
},
bases=("builder.element",),
),
]

View file

@ -52,6 +52,7 @@ class LocalBaserowListRowsUserServiceType(
type = "local_baserow_list_rows"
model_class = LocalBaserowListRows
max_result_limit = 200
returns_list = True
class SerializedDict(ServiceDict):
table_id: int

View file

@ -41,6 +41,9 @@ class ServiceType(
# unless instructed otherwise by a user.
default_result_limit = max_result_limit
# Does this service return a list of record?
returns_list = False
def prepare_values(
self, values: Dict[str, Any], user: AbstractUser
) -> Dict[str, Any]:

View file

@ -1,9 +1,13 @@
from copy import deepcopy
from baserow.contrib.builder.elements.models import (
CollectionElementField,
ColumnElement,
HeadingElement,
ImageElement,
LinkElement,
ParagraphElement,
TableElement,
)
@ -28,6 +32,36 @@ class ElementFixtures:
element = self.create_builder_element(LinkElement, user, page, **kwargs)
return element
def create_builder_table_element(self, user=None, page=None, **kwargs):
fields = kwargs.pop(
"fields",
deepcopy(
[
{"name": "Field 1", "value": "get('test1')"},
{"name": "Field 2", "value": "get('test2')"},
{"name": "Field 3", "value": "get('test3')"},
]
),
)
if "data_source" not in kwargs:
kwargs[
"data_source"
] = self.create_builder_local_baserow_list_rows_data_source(page=page)
element = self.create_builder_element(TableElement, user, page, **kwargs)
if fields:
created_fields = CollectionElementField.objects.bulk_create(
[
CollectionElementField(**field, order=index)
for index, field in enumerate(fields)
]
)
element.fields.add(*created_fields)
return element
def create_builder_element(self, model_class, user=None, page=None, **kwargs):
if user is None:
user = self.create_user()

View file

@ -0,0 +1,171 @@
import pytest
from rest_framework.exceptions import ValidationError
from baserow.contrib.builder.elements.models import CollectionElementField, Element
from baserow.contrib.builder.elements.registries import element_type_registry
from baserow.contrib.builder.elements.service import ElementService
@pytest.mark.django_db
def test_create_table_element_without_fields(data_fixture):
user = data_fixture.create_user()
page = data_fixture.create_builder_page(user=user)
data_source1 = data_fixture.create_builder_local_baserow_list_rows_data_source(
page=page
)
ElementService().create_element(
user,
element_type_registry.get("table"),
page=page,
data_source_id=data_source1.id,
fields=[],
)
created_element = Element.objects.last().specific
assert created_element.data_source.id == data_source1.id
@pytest.mark.django_db
def test_create_table_element_with_fields(data_fixture):
user = data_fixture.create_user()
page = data_fixture.create_builder_page(user=user)
data_source1 = data_fixture.create_builder_local_baserow_list_rows_data_source(
page=page
)
ElementService().create_element(
user,
element_type_registry.get("table"),
page=page,
data_source_id=data_source1.id,
fields=[
{"name": "Field 1", "value": "get('test')"},
{"name": "Field 2", "value": "get('test')"},
],
)
created_element = Element.objects.last().specific
assert created_element.data_source.id == data_source1.id
fields = list(created_element.fields.all())
assert len(fields) == 2
fields[0].name == "Field 1"
fields[1].name == "Field 2"
@pytest.mark.django_db
def test_create_table_element_with_non_collection_data_source(data_fixture):
user = data_fixture.create_user()
page = data_fixture.create_builder_page(user=user)
data_source1 = data_fixture.create_builder_local_baserow_get_row_data_source(
page=page
)
data_source2 = data_fixture.create_builder_data_source(page=page)
with pytest.raises(ValidationError):
ElementService().create_element(
user,
element_type_registry.get("table"),
page=page,
data_source_id=data_source1.id,
fields=[],
)
assert data_source2.service is None
with pytest.raises(ValidationError):
ElementService().create_element(
user,
element_type_registry.get("table"),
page=page,
data_source_id=data_source2.id,
fields=[],
)
@pytest.mark.django_db
def test_update_table_element_without_fields(data_fixture):
user = data_fixture.create_user()
page = data_fixture.create_builder_page(user=user)
data_source1 = data_fixture.create_builder_local_baserow_list_rows_data_source(
page=page
)
table_element = data_fixture.create_builder_table_element(page=page)
ElementService().update_element(
user,
table_element,
data_source_id=data_source1.id,
)
table_element.refresh_from_db()
assert table_element.data_source.id == data_source1.id
fields = list(table_element.fields.all())
assert len(fields) == 3
fields[0].name == "Field 1"
fields[1].name == "Field 2"
fields[2].name == "Field 3"
@pytest.mark.django_db
def test_update_table_element_without_bad_data_source_type(data_fixture):
user = data_fixture.create_user()
page = data_fixture.create_builder_page(user=user)
data_source1 = data_fixture.create_builder_local_baserow_get_row_data_source(
page=page
)
table_element = data_fixture.create_builder_table_element(page=page)
with pytest.raises(ValidationError):
ElementService().update_element(
user,
table_element,
data_source_id=data_source1.id,
)
@pytest.mark.django_db
def test_update_table_element_with_fields(data_fixture):
user = data_fixture.create_user()
page = data_fixture.create_builder_page(user=user)
table_element = data_fixture.create_builder_table_element(page=page)
ElementService().update_element(
user,
table_element,
fields=[
{"name": "New field 1", "value": "get('test')"},
{"name": "New field 2", "value": "get('test')"},
],
)
table_element.refresh_from_db()
fields = list(table_element.fields.all())
assert len(fields) == 2
fields[0].name == "New field 1"
fields[1].name == "New field 2"
@pytest.mark.django_db
def test_delete_table_element_remove_fields(data_fixture):
user = data_fixture.create_user()
page = data_fixture.create_builder_page(user=user)
table_element = data_fixture.create_builder_table_element(page=page)
assert CollectionElementField.objects.count() == 3
ElementService().delete_element(user, table_element)
assert CollectionElementField.objects.count() == 0

View file

@ -6,6 +6,7 @@ from baserow.contrib.builder.elements.models import (
Element,
HeadingElement,
ParagraphElement,
TableElement,
)
from baserow.contrib.builder.elements.registries import element_type_registry
from baserow.contrib.builder.models import Builder
@ -61,11 +62,15 @@ def test_builder_application_export(data_fixture):
page=page2, user=user, name="source 3", integration=integration
)
element4 = data_fixture.create_builder_table_element(
page=page2, data_source=datasource3
)
serialized = BuilderApplicationType().export_serialized(
builder, ImportExportConfig(include_permission_data=True)
)
assert serialized == {
reference = {
"pages": [
{
"id": page1.id,
@ -182,6 +187,20 @@ def test_builder_application_export(data_fixture):
"value": element3.value,
"level": element3.level,
},
{
"id": element4.id,
"type": "table",
"order": str(element4.order),
"parent_element_id": None,
"place_in_container": None,
"style_padding_top": 10,
"style_padding_bottom": 10,
"data_source_id": element4.data_source.id,
"fields": [
{"name": f.name, "value": f.value}
for f in element4.fields.all()
],
},
],
},
],
@ -210,6 +229,8 @@ def test_builder_application_export(data_fixture):
"type": "builder",
}
assert serialized == reference
IMPORT_REFERENCE = {
"pages": [
@ -237,6 +258,18 @@ IMPORT_REFERENCE = {
"order": 2,
"value": "",
},
{
"id": 1000,
"type": "table",
"parent_element_id": None,
"place_in_container": None,
"order": 2.5,
"data_source_id": 5,
"fields": [
{"name": "F 1", "value": "get('test1')"},
{"name": "F 2", "value": "get('test2')"},
],
},
{
"id": 500,
"type": "column",
@ -375,7 +408,7 @@ def test_builder_application_import(data_fixture):
[page1, page2] = builder.page_set.all()
assert page1.element_set.count() == 4
assert page1.element_set.count() == 5
assert page2.element_set.count() == 1
assert page1.datasource_set.count() == 2
@ -399,12 +432,16 @@ def test_builder_application_import(data_fixture):
element1,
element_inside_container,
element2,
table_element,
container_element,
] = specific_iterator(page1.element_set.all())
assert isinstance(element1, HeadingElement)
assert isinstance(element2, ParagraphElement)
assert isinstance(container_element, ColumnElement)
assert isinstance(table_element, TableElement)
assert table_element.fields.count() == 2
assert element1.order == 1
assert element1.level == 2