1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-14 17:18:33 +00:00
bramw_baserow/backend/src/baserow/contrib/builder/elements/models.py
2024-07-05 15:41:24 +00:00

914 lines
26 KiB
Python

import uuid
from typing import TYPE_CHECKING, 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 SET_NULL, QuerySet
from baserow.contrib.builder.constants import (
BACKGROUND_IMAGE_MODES,
WIDTHS,
HorizontalAlignments,
VerticalAlignments,
)
from baserow.core.formula.field import FormulaField
from baserow.core.mixins import (
CreatedAndUpdatedOnMixin,
FractionOrderableMixin,
HierarchicalModelMixin,
PolymorphicContentTypeMixin,
TrashableModelMixin,
WithRegistry,
)
from baserow.core.user_files.models import UserFile
if TYPE_CHECKING:
from baserow.contrib.builder.pages.models import Page
class BackgroundTypes(models.TextChoices):
NONE = "none"
COLOR = "color"
IMAGE = "image"
class WidthTypes(models.TextChoices):
FULL = "full"
FULL_WIDTH = "full-width"
NORMAL = "normal"
MEDIUM = "medium"
SMALL = "small"
class INPUT_TEXT_TYPES(models.TextChoices):
TEXT = "text"
PASSWORD = "password" # nosec bandit B105
def get_default_element_content_type():
return ContentType.objects.get_for_model(Element)
def get_default_table_orientation():
return {
"smartphone": "horizontal",
"tablet": "horizontal",
"desktop": "horizontal",
}
class Element(
HierarchicalModelMixin,
TrashableModelMixin,
CreatedAndUpdatedOnMixin,
FractionOrderableMixin,
PolymorphicContentTypeMixin,
WithRegistry,
models.Model,
):
"""
This model represents a page element. An element is a piece of the page that
display an information or something the user can interact with.
"""
class VISIBILITY_TYPES(models.TextChoices):
ALL = "all"
LOGGED_IN = "logged-in"
NOT_LOGGED = "not-logged"
class ROLE_TYPES(models.TextChoices):
ALLOW_ALL = "allow_all"
ALLOW_ALL_EXCEPT = "allow_all_except"
DISALLOW_ALL_EXCEPT = "disallow_all_except"
page = models.ForeignKey("builder.Page", on_delete=models.CASCADE)
order = models.DecimalField(
help_text="Lowest first.",
max_digits=40,
decimal_places=20,
editable=False,
default=1,
)
content_type = models.ForeignKey(
ContentType,
verbose_name="content type",
related_name="page_elements",
on_delete=models.SET(get_default_element_content_type),
)
# This is used for container elements, if NULL then this is a root element
parent_element = models.ForeignKey(
"self",
on_delete=models.CASCADE,
null=True,
default=None,
help_text="The parent element, if inside a container.",
related_name="children",
)
role_type = models.CharField(
choices=ROLE_TYPES.choices,
max_length=19,
default=ROLE_TYPES.ALLOW_ALL,
db_index=True,
# TODO: null=True to be removed in the next release.
# See: https://gitlab.com/baserow/baserow/-/issues/2724
null=True,
)
roles = models.JSONField(
default=list,
help_text="User roles associated with this element, used in conjunction with role_type.",
# TODO: null=True to be removed in the next release.
# See: https://gitlab.com/baserow/baserow/-/issues/2724
null=True,
)
# The following fields are used to store the position of the element in the
# container. If the element is a root element then this is null.
place_in_container = models.CharField(
null=True,
blank=True,
default=None,
max_length=255,
help_text="The place in the container.",
)
visibility = models.CharField(
choices=VISIBILITY_TYPES.choices,
max_length=20,
default=VISIBILITY_TYPES.ALL,
db_index=True,
)
styles = models.JSONField(
default=dict,
help_text="The theme overrides for this element",
null=True, # TODO zdm remove me in next release
)
style_border_top_color = models.CharField(
max_length=20,
default="border",
blank=True,
help_text="Top border color.",
)
style_border_top_size = models.PositiveIntegerField(
default=0, help_text="Pixel height of the top border."
)
style_padding_top = models.PositiveIntegerField(
default=10, help_text="Padding size of the top border."
)
style_margin_top = models.PositiveIntegerField(
default=0,
help_text="Margin size of the top border.",
null=True, # TODO zdm remove me after v1.26
)
style_border_bottom_color = models.CharField(
max_length=20,
default="border",
blank=True,
help_text="Bottom border color",
)
style_border_bottom_size = models.PositiveIntegerField(
default=0, help_text="Pixel height of the bottom border."
)
style_padding_bottom = models.PositiveIntegerField(
default=10, help_text="Padding size of the bottom border."
)
style_margin_bottom = models.PositiveIntegerField(
default=0,
help_text="Margin size of the bottom border.",
null=True, # TODO zdm remove me after v1.26
)
style_border_left_color = models.CharField(
max_length=20,
default="border",
blank=True,
help_text="Left border color",
)
style_border_left_size = models.PositiveIntegerField(
default=0, help_text="Pixel height of the left border."
)
style_padding_left = models.PositiveIntegerField(
default=20, help_text="Padding size of the left border."
)
style_margin_left = models.PositiveIntegerField(
default=0,
help_text="Margin size of the left border.",
null=True, # TODO zdm remove me after v1.26
)
style_border_right_color = models.CharField(
max_length=20,
default="border",
blank=True,
help_text="Right border color",
)
style_border_right_size = models.PositiveIntegerField(
default=0, help_text="Pixel height of the right border."
)
style_padding_right = models.PositiveIntegerField(
default=20, help_text="Padding size of the right border."
)
style_margin_right = models.PositiveIntegerField(
default=0,
help_text="Margin size of the right border.",
null=True, # TODO zdm remove me after v1.26
)
style_background = models.CharField(
choices=BackgroundTypes.choices,
default=BackgroundTypes.NONE,
help_text="What type of background the element should have.",
max_length=20,
)
style_background_color = models.CharField(
max_length=20,
default="#ffffffff",
blank=True,
help_text="The background color if `style_background` is color.",
)
style_background_file = models.ForeignKey(
UserFile,
null=True,
on_delete=models.SET_NULL,
related_name="element_background_image_file",
help_text="An image file uploaded by the user to be used as element background",
)
style_background_mode = models.CharField(
help_text="The mode of the background image",
choices=BACKGROUND_IMAGE_MODES.choices,
max_length=32,
default=BACKGROUND_IMAGE_MODES.FILL,
null=True, # TODO zdm remove me after v1.26
)
style_width = models.CharField(
choices=WidthTypes.choices,
default=WidthTypes.NORMAL,
help_text="Indicates the width of the element.",
max_length=20,
)
class Meta:
ordering = ("order", "id")
@staticmethod
def get_type_registry():
from .registries import element_type_registry
return element_type_registry
def get_parent(self):
return self.page
def get_sibling_elements(self):
return Element.objects.filter(
parent_element=self.parent_element, page=self.page
).exclude(id=self.id)
@property
def is_root_element(self):
return self.parent_element is None
@classmethod
def get_last_order(
cls,
page: "Page",
parent_element_id: Optional[int] = None,
place_in_container: Optional[str] = None,
):
"""
Returns the last order for the given page.
:param page: The page we want the order for.
:param base_queryset: The base queryset to use.
:return: The last order.
"""
return cls.get_last_orders(page, parent_element_id, place_in_container)[0]
@classmethod
def get_last_orders(
cls,
page: "Page",
parent_element_id: Optional[int] = None,
place_in_container: Optional[str] = None,
amount=1,
):
"""
Returns the last orders for the given page.
:param page: The page we want the order for.
:param parent_element_id: The id of the parent element.
:param place_in_container: The place in the container
:param amount: The number of orders you wish to have returned
:return: The last order.
"""
queryset = Element.objects.filter(page=page)
queryset = cls._scope_queryset_to_container(
queryset, parent_element_id, place_in_container
)
return cls.get_highest_order_of_queryset(queryset, amount=amount)
@classmethod
def get_unique_order_before_element(
cls, before: "Element", parent_element_id: int, place_in_container: str
):
"""
Returns a safe order value before the given element in the given page.
:param before: The element before which we want the safe order
:param parent_element_id: The id of the parent element.
:param place_in_container: The place in the container
:raises CannotCalculateIntermediateOrder: If it's not possible to find an
intermediate order. The full order of the items must be recalculated in this
case before calling this method again.
:return: The order value.
"""
queryset = Element.objects.filter(page=before.page)
queryset = cls._scope_queryset_to_container(
queryset, parent_element_id, place_in_container
)
return cls.get_unique_orders_before_item(before, queryset)[0]
@classmethod
def _scope_queryset_to_container(
cls, queryset: QuerySet, parent_element_id: int, place_in_container: str
) -> QuerySet:
"""
Filters the queryset to only include elements that are in the same container
as the child element.
:param queryset: The queryset to filter.
:param parent_element_id: The ID of the parent element.
:param place_in_container: The place in container of the child element.
:return: The filtered queryset.
"""
if parent_element_id:
return queryset.filter(
parent_element_id=parent_element_id,
place_in_container=place_in_container,
)
else:
return queryset.filter(
parent_element_id=None,
)
class ContainerElement(Element):
"""
Base class for container elements.
"""
class Meta:
abstract = True
class ColumnElement(ContainerElement):
"""
A column element that can contain other elements.
"""
column_amount = models.IntegerField(
default=3,
help_text="The amount of columns inside this column element.",
validators=[
MinValueValidator(1, message="Value cannot be less than 0."),
MaxValueValidator(6, message="Value cannot be greater than 6."),
],
)
column_gap = models.IntegerField(
default=20,
help_text="The amount of space between the columns.",
validators=[
MinValueValidator(0, message="Value cannot be less than 0."),
MaxValueValidator(2000, message="Value cannot be greater than 2000."),
],
)
alignment = models.CharField(
choices=VerticalAlignments.choices,
max_length=10,
default=VerticalAlignments.TOP,
)
class HeadingElement(Element):
"""
A Heading element to display a title.
"""
class HeadingLevel(models.IntegerChoices):
H1 = 1
H2 = 2
H3 = 3
H4 = 4
H5 = 5
value = FormulaField(default="")
level = models.IntegerField(
choices=HeadingLevel.choices, default=1, help_text="The level of the heading"
)
# TODO zdm remove following fields in next release (after 1.26)
font_color = models.CharField(
max_length=20,
default="default",
blank=True,
help_text="The font color of the heading",
)
# TODO zdm remove following fields in next release (after 1.26)
alignment = models.CharField(
choices=HorizontalAlignments.choices,
max_length=10,
default=HorizontalAlignments.LEFT,
)
class TextElement(Element):
"""
A simple blob of text.
"""
class TEXT_FORMATS(models.TextChoices):
PLAIN = "plain"
MARKDOWN = "markdown"
value = FormulaField(default="")
format = models.CharField(
choices=TEXT_FORMATS.choices,
help_text="The format of the text",
max_length=10,
default=TEXT_FORMATS.PLAIN,
)
# TODO zdm remove following fields in next release (after 1.26)
alignment = models.CharField(
choices=HorizontalAlignments.choices,
max_length=10,
default=HorizontalAlignments.LEFT,
)
class NavigationElementMixin(models.Model):
"""
Abstract base class for navigation elements.
"""
class NAVIGATION_TYPES(models.TextChoices):
PAGE = "page"
CUSTOM = "custom"
class TARGETS(models.TextChoices):
SELF = "self"
BLANK = "blank"
navigation_type = models.CharField(
choices=NAVIGATION_TYPES.choices,
help_text="The navigation type.",
max_length=10,
default=NAVIGATION_TYPES.PAGE,
null=True,
)
navigate_to_page = models.ForeignKey(
"builder.Page",
null=True,
on_delete=models.SET_NULL,
help_text=(
"Destination page id for this link. If null then we use the "
"navigate_to_url property instead.",
),
)
navigate_to_url = FormulaField(
default="",
help_text="If no page is selected, this indicate the destination of the link.",
null=True,
)
page_parameters = models.JSONField(
default=list,
help_text="The parameters for each parameters of the selected page if any.",
null=True,
)
target = models.CharField(
choices=TARGETS.choices,
help_text="The target of the link when we click on it.",
max_length=10,
default=TARGETS.SELF,
null=True,
)
class Meta:
abstract = True
class LinkElement(Element, NavigationElementMixin):
"""
A simple link.
"""
class VARIANTS(models.TextChoices):
LINK = "link"
BUTTON = "button"
value = FormulaField(default="")
variant = models.CharField(
choices=VARIANTS.choices,
help_text="The variant of the link.",
max_length=10,
default=VARIANTS.LINK,
)
# TODO zdm remove following fields in next release (after 1.26)
width = models.CharField(
choices=WIDTHS.choices,
max_length=10,
default=WIDTHS.AUTO,
)
# TODO zdm remove following fields in next release (after 1.26)
alignment = models.CharField(
choices=HorizontalAlignments.choices,
max_length=10,
default=HorizontalAlignments.LEFT,
)
# TODO zdm remove me in next release
button_color = models.CharField(
max_length=20,
default="primary",
blank=True,
help_text="The color of the button",
)
class ImageElement(Element):
"""
A simple image element that can display an image either through a remote source
or via an uploaded file
"""
class IMAGE_SOURCE_TYPES(models.TextChoices):
UPLOAD = "upload"
URL = "url"
class IMAGE_CONSTRAINT_TYPES(models.TextChoices):
COVER = "cover"
CONTAIN = "contain"
FULL_WIDTH = "full-width"
image_source_type = models.CharField(
choices=IMAGE_SOURCE_TYPES.choices,
max_length=32,
default=IMAGE_SOURCE_TYPES.UPLOAD,
)
image_file = models.ForeignKey(
UserFile,
null=True,
on_delete=models.SET_NULL,
related_name="image_element_image_file",
help_text="An image file uploaded by the user to be used by the element",
)
image_url = FormulaField(
help_text="A link to the image file", blank=True, default="", max_length=1000
)
alt_text = FormulaField(
help_text="Text that is displayed when the image can't load",
default="",
blank=True,
)
# TODO zdm remove following field in next release (after 1.26)
alignment = models.CharField(
choices=HorizontalAlignments.choices,
max_length=10,
default=HorizontalAlignments.LEFT,
)
# TODO zdm remove following field in next release (after 1.26)
style_max_width = models.PositiveIntegerField(
null=True,
help_text="The max-width for this image element.",
default=100,
validators=[
MinValueValidator(0, message="Value cannot be less than 0."),
MaxValueValidator(100, message="Value cannot be greater than 100."),
],
)
# TODO zdm remove following field in next release (after 1.26)
style_max_height = models.PositiveIntegerField(
null=True,
help_text="The max-height for this image element.",
validators=[
MinValueValidator(5, message="Value cannot be less than 5."),
MaxValueValidator(3000, message="Value cannot be greater than 3000."),
],
)
# TODO zdm remove following field in next release (after 1.26)
style_image_constraint = models.CharField(
help_text="The image constraint to apply to this image",
choices=IMAGE_CONSTRAINT_TYPES.choices,
max_length=32,
default=IMAGE_CONSTRAINT_TYPES.CONTAIN,
)
class FormContainerElement(ContainerElement):
"""
A form element
"""
submit_button_label = FormulaField(default="")
reset_initial_values_post_submission = models.BooleanField(
default=False,
help_text="Whether to reset the form to using its initial "
"values after a successful form submission.",
)
# TODO zdm remove following fields in next release (after 1.26)
button_color = models.CharField(
max_length=20,
default="primary",
blank=True,
help_text="The color of the button",
)
class FormElement(Element):
"""
The base form element, which can be extended to
support an element for each supported type.
"""
required = models.BooleanField(
default=False, help_text="Whether this form element is a required field."
)
class Meta:
abstract = True
class InputTextElement(FormElement):
"""
An input element of text type.
"""
class INPUT_TEXT_VALIDATION_TYPES(models.TextChoices):
ANY = "any"
EMAIL = "email"
INTEGER = "integer"
label = FormulaField(
default="",
help_text="The text label for this input",
)
default_value = FormulaField(
default="", help_text="This text input's default value."
)
validation_type = models.CharField(
max_length=15,
choices=INPUT_TEXT_VALIDATION_TYPES.choices,
default=INPUT_TEXT_VALIDATION_TYPES.ANY,
help_text="Optionally set the validation type to use when applying form data.",
)
placeholder = FormulaField(
default="",
help_text="The placeholder text which should be applied to the element.",
)
is_multiline = models.BooleanField(
default=False,
help_text="Whether this text input is multiline.",
)
rows = models.PositiveIntegerField(
default=3,
help_text="Number of rows displayed by the rendered input element",
)
input_type = models.CharField(
max_length=10,
choices=INPUT_TEXT_TYPES.choices,
default=INPUT_TEXT_TYPES.TEXT,
help_text="The type of the input, not applicable for multiline inputs.",
null=True, # TODO zdm remove me in next release
)
class ChoiceElement(FormElement):
label = FormulaField(
default="",
help_text="The text label for this choice",
)
default_value = FormulaField(
default="",
help_text="This choice's input default value.",
)
placeholder = FormulaField(
default="",
help_text="The placeholder text which should be applied to the element.",
)
multiple = models.BooleanField(
default=False,
help_text="Whether this choice allows users to choose multiple values.",
null=True, # TODO zdm remove me in next release
)
show_as_dropdown = models.BooleanField(
default=True,
help_text="Whether to show the choices as a dropdown.",
null=True, # TODO zdm remove me in next release
)
class ChoiceElementOption(models.Model):
value = models.TextField(
blank=True,
default="",
help_text="The value of the option",
)
name = models.TextField(
blank=True,
default="",
help_text="The display name of the option",
)
choice = models.ForeignKey(
ChoiceElement,
on_delete=models.CASCADE,
)
class CheckboxElement(FormElement):
"""
A checkbox element.
"""
label = FormulaField(
default="",
help_text="The text label for this input",
)
default_value = FormulaField(default="", help_text="The input's default value.")
class ButtonElement(Element):
"""
A button element
"""
value = FormulaField(default="", help_text="The caption of the button.")
# TODO zdm remove following fields in next release (after 1.26)
width = models.CharField(
choices=WIDTHS.choices,
max_length=10,
default=WIDTHS.AUTO,
)
# TODO zdm remove following fields in next release (after 1.26)
alignment = models.CharField(
choices=HorizontalAlignments.choices,
max_length=10,
default=HorizontalAlignments.LEFT,
)
# TODO zdm remove following fields in next release (after 1.26)
button_color = models.CharField(
max_length=20,
default="primary",
blank=True,
help_text="The color of the button",
)
class CollectionField(models.Model):
"""
A field of a Collection element
"""
uid = models.UUIDField(default=uuid.uuid4)
order = models.PositiveIntegerField()
name = models.CharField(
max_length=225,
help_text="The name of the field.",
)
type = models.CharField(
max_length=225,
help_text="The type of the field.",
)
config = models.JSONField(
default=dict,
help_text="The configuration of the field.",
)
def get_type(self):
"""Returns the type for this model instance"""
from .registries import collection_field_type_registry
return collection_field_type_registry.get(self.type)
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.",
)
items_per_page = models.PositiveIntegerField(
default=20,
help_text="The amount item loaded with each page.",
validators=[
MinValueValidator(1, message="Value cannot be less than 1."),
MaxValueValidator(100, message="Value cannot be greater than 100."),
],
)
button_load_more_label = FormulaField(
help_text="The label of the show more button",
blank=True,
default="",
null=True, # TODO zdm remove me in next release (after 1.26)
)
class Meta:
abstract = True
class TableElement(CollectionElement):
"""
A table element
"""
orientation = models.JSONField(
blank=True,
null=True,
default=get_default_table_orientation,
help_text="The table orientation (horizontal or vertical) for each device type",
)
fields = models.ManyToManyField(CollectionField)
# TODO zdm remove following fields in next release (after 1.26)
button_color = models.CharField(
max_length=20,
default="primary",
blank=True,
help_text="The color of the button",
)
class IFrameElement(Element):
"""
An element for embedding external resources in the application.
"""
class IFRAME_SOURCE_TYPE(models.TextChoices):
URL = "url"
EMBED = "embed"
source_type = models.CharField(
choices=IFRAME_SOURCE_TYPE.choices,
max_length=32,
default=IFRAME_SOURCE_TYPE.URL,
)
url = FormulaField(
help_text="A link to the page to embed",
blank=True,
default="",
)
embed = FormulaField(help_text="Inline HTML to embed", blank=True, default="")
height = models.PositiveIntegerField(
help_text="Height in pixels of the iframe",
default=300,
)
class RepeatElement(CollectionElement, ContainerElement):
"""
A container and collection type element which repeats the child elements for each
item in the data source that it is bound to.
"""
class ORIENTATIONS(models.TextChoices):
VERTICAL = "vertical"
HORIZONTAL = "horizontal"
orientation = models.CharField(
choices=ORIENTATIONS.choices,
max_length=10,
default=ORIENTATIONS.VERTICAL,
)
items_per_row = models.JSONField(
default=dict,
help_text="The amount repetitions per row, per device type. "
"Only applicable when the orientation is horizontal.",
)