mirror of
https://gitlab.com/bramw/baserow.git
synced 2025-04-14 00:59:06 +00:00
1020 lines
34 KiB
Python
1020 lines
34 KiB
Python
import itertools
|
|
import secrets
|
|
from typing import Iterable, Optional, Union
|
|
|
|
from django.contrib.auth.hashers import check_password, make_password
|
|
from django.contrib.auth.models import User
|
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from django.contrib.postgres.fields import ArrayField
|
|
from django.db import models
|
|
from django.db.models import Q
|
|
from django.db.models.query import Prefetch
|
|
from django.utils.functional import lazy
|
|
|
|
from baserow.contrib.database.fields.field_filters import (
|
|
FILTER_TYPE_AND,
|
|
FILTER_TYPE_OR,
|
|
)
|
|
from baserow.contrib.database.fields.models import Field, SelectOption
|
|
from baserow.contrib.database.views.registries import (
|
|
form_view_mode_registry,
|
|
view_filter_type_registry,
|
|
view_type_registry,
|
|
)
|
|
from baserow.core.db import specific_queryset
|
|
from baserow.core.mixins import (
|
|
CreatedAndUpdatedOnMixin,
|
|
HierarchicalModelMixin,
|
|
OrderableMixin,
|
|
PolymorphicContentTypeMixin,
|
|
TrashableModelMixin,
|
|
WithRegistry,
|
|
)
|
|
from baserow.core.models import UserFile
|
|
from baserow.core.utils import get_model_reference_field_name
|
|
|
|
FILTER_TYPES = ((FILTER_TYPE_AND, "And"), (FILTER_TYPE_OR, "Or"))
|
|
|
|
SORT_ORDER_ASC = "ASC"
|
|
SORT_ORDER_DESC = "DESC"
|
|
SORT_ORDER_CHOICES = ((SORT_ORDER_ASC, "Ascending"), (SORT_ORDER_DESC, "Descending"))
|
|
|
|
FORM_VIEW_SUBMIT_TEXT = "Submit"
|
|
FORM_VIEW_SUBMIT_ACTION_MESSAGE = "MESSAGE"
|
|
FORM_VIEW_SUBMIT_ACTION_REDIRECT = "REDIRECT"
|
|
FORM_VIEW_SUBMIT_ACTION_CHOICES = (
|
|
(FORM_VIEW_SUBMIT_ACTION_MESSAGE, "Message"),
|
|
(FORM_VIEW_SUBMIT_ACTION_REDIRECT, "Redirect"),
|
|
)
|
|
|
|
OWNERSHIP_TYPE_COLLABORATIVE = "collaborative"
|
|
DEFAULT_OWNERSHIP_TYPE = OWNERSHIP_TYPE_COLLABORATIVE
|
|
VIEW_OWNERSHIP_TYPES = [OWNERSHIP_TYPE_COLLABORATIVE]
|
|
|
|
# Must be the same as `modules/database/constants.js`.
|
|
DEFAULT_FORM_VIEW_FIELD_COMPONENT_KEY = "default"
|
|
|
|
# Must be the same as `modules/database/constants.js`.
|
|
DEFAULT_SORT_TYPE_KEY = "default"
|
|
|
|
|
|
def get_default_view_content_type():
|
|
return ContentType.objects.get_for_model(View)
|
|
|
|
|
|
class View(
|
|
HierarchicalModelMixin,
|
|
TrashableModelMixin,
|
|
CreatedAndUpdatedOnMixin,
|
|
OrderableMixin,
|
|
PolymorphicContentTypeMixin,
|
|
models.Model,
|
|
WithRegistry,
|
|
):
|
|
table = models.ForeignKey("database.Table", on_delete=models.CASCADE)
|
|
order = models.PositiveIntegerField()
|
|
name = models.CharField(max_length=255)
|
|
content_type = models.ForeignKey(
|
|
ContentType,
|
|
verbose_name="content type",
|
|
related_name="database_views",
|
|
on_delete=models.SET(get_default_view_content_type),
|
|
)
|
|
filter_type = models.CharField(
|
|
max_length=3,
|
|
choices=FILTER_TYPES,
|
|
default=FILTER_TYPE_AND,
|
|
help_text="Indicates whether all the rows should apply to all filters (AND) "
|
|
"or to any filter (OR).",
|
|
)
|
|
filters_disabled = models.BooleanField(
|
|
default=False,
|
|
help_text="Allows users to see results unfiltered while still keeping "
|
|
"the filters saved for the view.",
|
|
)
|
|
slug = models.SlugField(
|
|
default=secrets.token_urlsafe,
|
|
help_text="The unique slug where the view can be accessed publicly on.",
|
|
unique=True,
|
|
db_index=True,
|
|
)
|
|
public = models.BooleanField(
|
|
default=False,
|
|
help_text="Indicates whether the view is publicly accessible to visitors.",
|
|
db_index=True,
|
|
)
|
|
public_view_password = models.CharField(
|
|
# PLEASE NOTE: This max_length is not for the password that the user inputs,
|
|
# but instead to fit the hashed and salted password generated by Django!
|
|
# See the UpdateViewSerializer for the validations on how long a user
|
|
# password can be!
|
|
max_length=128,
|
|
blank=True,
|
|
help_text="The password required to access the public view URL.",
|
|
)
|
|
show_logo = models.BooleanField(
|
|
default=True,
|
|
help_text="Indicates whether the logo should be shown in the public view.",
|
|
)
|
|
allow_public_export = models.BooleanField(
|
|
default=False,
|
|
db_default=False,
|
|
help_text="Indicates whether it's allowed to export a publicly shared view.",
|
|
)
|
|
owned_by = models.ForeignKey(
|
|
User,
|
|
null=True,
|
|
on_delete=models.SET_NULL,
|
|
db_column="created_by_id",
|
|
)
|
|
ownership_type = models.CharField(
|
|
max_length=255,
|
|
default=DEFAULT_OWNERSHIP_TYPE,
|
|
help_text=(
|
|
"Indicates how the access to the view is determined."
|
|
" By default, views are collaborative and shared for all users"
|
|
" that have access to the table."
|
|
),
|
|
)
|
|
db_index_name = models.CharField(
|
|
max_length=30,
|
|
null=True,
|
|
blank=True,
|
|
help_text="The name of the database index that is used to speed up the "
|
|
"filtering of the view.",
|
|
)
|
|
|
|
@staticmethod
|
|
def get_type_registry():
|
|
"""Returns the registry related to this model class."""
|
|
|
|
return view_type_registry
|
|
|
|
@property
|
|
def public_view_has_password(self) -> bool:
|
|
"""
|
|
Indicates whether the public view is password protected or not.
|
|
|
|
:return: True if the public view is password protected, False otherwise.
|
|
"""
|
|
|
|
return self.public_view_password != "" # nosec b105
|
|
|
|
def get_parent(self):
|
|
return self.table
|
|
|
|
def rotate_slug(self):
|
|
"""
|
|
Rotates the slug used to address this view.
|
|
"""
|
|
|
|
self.slug = View.create_new_slug()
|
|
|
|
@staticmethod
|
|
def create_new_slug() -> str:
|
|
"""
|
|
Create a new slug for a view.
|
|
|
|
:return: The new slug.
|
|
"""
|
|
|
|
return secrets.token_urlsafe()
|
|
|
|
@staticmethod
|
|
def make_password(password: str) -> str:
|
|
"""
|
|
Makes a password hash from the given password.
|
|
|
|
:param password: The password to hash.
|
|
:return: The hashed password.
|
|
"""
|
|
|
|
return make_password(password)
|
|
|
|
def set_password(self, password: str):
|
|
"""
|
|
Sets the public view password.
|
|
|
|
:param password: The password to set.
|
|
"""
|
|
|
|
self.public_view_password = View.make_password(password)
|
|
|
|
def check_public_view_password(self, password: str) -> bool:
|
|
"""
|
|
Checks if the given password matches the public view password.
|
|
|
|
:param password: The password to check.
|
|
:return: True if the password matches, False otherwise.
|
|
"""
|
|
|
|
if not self.public_view_has_password:
|
|
return True
|
|
return check_password(password, self.public_view_password)
|
|
|
|
class Meta:
|
|
ordering = ("order",)
|
|
|
|
def get_all_sorts(
|
|
self, restrict_to_field_ids: Optional[Iterable[int]] = None
|
|
) -> Iterable["Union[ViewGroupBy, ViewSort]"]:
|
|
"""
|
|
Returns any applied ViewGroupBys and ViewSorts on this view. A view should
|
|
be sorted first by the ViewGroupBys, and then it's ViewSorts.
|
|
|
|
:param restrict_to_field_ids: If provided only view group bys and sorts will be
|
|
returned for fields with an id in this iterable.
|
|
"""
|
|
|
|
can_group_by = view_type_registry.get_by_model(self.specific_class).can_group_by
|
|
viewsorts_qs = self.viewsort_set
|
|
if restrict_to_field_ids is not None:
|
|
viewsorts_qs = viewsorts_qs.filter(field_id__in=restrict_to_field_ids)
|
|
|
|
if can_group_by:
|
|
viewgroupbys_qs = self.viewgroupby_set
|
|
if restrict_to_field_ids is not None:
|
|
viewgroupbys_qs = viewgroupbys_qs.filter(
|
|
field_id__in=restrict_to_field_ids
|
|
)
|
|
# GroupBy's have higher priority and must be sorted by first.
|
|
return itertools.chain(viewgroupbys_qs.all(), viewsorts_qs.all())
|
|
else:
|
|
return viewsorts_qs.all()
|
|
|
|
@classmethod
|
|
def get_last_order(cls, table):
|
|
queryset = View.objects.filter(table=table)
|
|
return cls.get_highest_order_of_queryset(queryset) + 1
|
|
|
|
def get_field_options(self, create_if_missing=False, fields=None):
|
|
"""
|
|
Each field can have unique options per view. This method returns those
|
|
options per field type and can optionally create the missing ones. This method
|
|
only works if the `field_options` property is a ManyToManyField with a relation
|
|
to a field options model.
|
|
|
|
:param create_if_missing: If true the missing GridViewFieldOptions are
|
|
going to be created. If a fields has been created at a later moment it
|
|
could be possible that they don't exist yet. If this value is True, the
|
|
missing relationships are created in that case.
|
|
:type create_if_missing: bool
|
|
:param fields: If all the fields related to the table of this grid view have
|
|
already been fetched, they can be provided here to avoid having to fetch
|
|
them for a second time. This is only needed if `create_if_missing` is True.
|
|
:type fields: list
|
|
:return: A queryset containing all the field options of view.
|
|
:rtype: QuerySet
|
|
"""
|
|
|
|
view_type = view_type_registry.get_by_model(self.specific_class)
|
|
through_model = view_type.field_options_model_class
|
|
|
|
if not through_model:
|
|
raise ValueError(
|
|
f"The view type {view_type.type} does not support field options."
|
|
)
|
|
|
|
field_name = get_model_reference_field_name(through_model, View)
|
|
|
|
if not field_name:
|
|
raise ValueError(
|
|
"The through model doesn't have a relationship with the View model or "
|
|
"any descendants."
|
|
)
|
|
|
|
def get_queryset():
|
|
return view_type.enhance_field_options_queryset(
|
|
through_model.objects.filter(
|
|
**{field_name: self, "field__table_id": self.table_id}
|
|
)
|
|
)
|
|
|
|
field_options = get_queryset()
|
|
|
|
if create_if_missing:
|
|
if fields is None:
|
|
fields = Field.objects.filter(table_id=self.table.id)
|
|
field_count = fields.count()
|
|
else:
|
|
field_count = len(fields)
|
|
|
|
# The check there are missing field options must be as efficient as
|
|
# possible because this is being done a lot.
|
|
if len(field_options) < field_count:
|
|
self.create_missing_field_options(field_options, fields)
|
|
field_options = get_queryset()
|
|
|
|
return field_options
|
|
|
|
def create_missing_field_options(self, existing_field_options, fields) -> Iterable:
|
|
view_type = view_type_registry.get_by_model(self.specific_class)
|
|
through_model = view_type.field_options_model_class
|
|
|
|
# In the case when field options are missing, we can be more
|
|
# in-efficient because this rarely happens. The most important part
|
|
# is that the check is fast.
|
|
existing_field_ids = [options.field_id for options in existing_field_options]
|
|
new_field_options = through_model.objects.bulk_create(
|
|
[
|
|
view_type.prepare_field_options(self, field.id)
|
|
for field in fields
|
|
if field.id not in existing_field_ids
|
|
],
|
|
ignore_conflicts=True,
|
|
)
|
|
return new_field_options
|
|
|
|
|
|
class ViewFilterManager(models.Manager):
|
|
"""
|
|
Manager for the ViewFilter model.
|
|
The View can be trashed and the filters are not deleted, therefore
|
|
we need to filter out the trashed views.
|
|
"""
|
|
|
|
def get_queryset(self):
|
|
trashed_Q = Q(view__trashed=True) | Q(field__trashed=True)
|
|
return super().get_queryset().filter(~trashed_Q)
|
|
|
|
|
|
class FilterGroupMixin(models.Model):
|
|
filter_type = models.CharField(
|
|
max_length=3,
|
|
choices=FILTER_TYPES,
|
|
default=FILTER_TYPE_AND,
|
|
help_text="Indicates whether all the rows should apply to all filters (AND) "
|
|
"or to any filter (OR) in the group to be shown.",
|
|
)
|
|
parent_group = models.ForeignKey("self", on_delete=models.CASCADE, null=True)
|
|
|
|
class Meta:
|
|
abstract = True
|
|
|
|
|
|
class ViewFilterGroup(HierarchicalModelMixin, FilterGroupMixin):
|
|
view = models.ForeignKey(
|
|
View,
|
|
on_delete=models.CASCADE,
|
|
help_text="The view to which the filter group applies to. "
|
|
"Each view can have its own filter groups.",
|
|
related_name="filter_groups",
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ("id",)
|
|
|
|
def get_parent(self):
|
|
return self.view
|
|
|
|
|
|
class ViewFilter(HierarchicalModelMixin, models.Model):
|
|
objects = ViewFilterManager()
|
|
|
|
group = models.ForeignKey(
|
|
ViewFilterGroup,
|
|
on_delete=models.CASCADE,
|
|
null=True,
|
|
help_text="The filter group to which the filter applies. "
|
|
"Each view can have his own filters.",
|
|
related_name="filters",
|
|
)
|
|
view = models.ForeignKey(
|
|
View,
|
|
on_delete=models.CASCADE,
|
|
help_text="The view to which the filter applies. Each view can have his own "
|
|
"filters.",
|
|
)
|
|
field = models.ForeignKey(
|
|
"database.Field",
|
|
on_delete=models.CASCADE,
|
|
help_text="The field of which the value must be compared to the filter value.",
|
|
)
|
|
type = models.CharField(
|
|
max_length=48,
|
|
help_text="Indicates how the field's value must be compared to the filter's "
|
|
"value. The filter is always in this order `field` `type` `value` "
|
|
"(example: `field_1` `contains` `Test`).",
|
|
)
|
|
value = models.TextField(
|
|
blank=True,
|
|
help_text="The filter value that must be compared to the field's value.",
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ("id",)
|
|
|
|
@property
|
|
def preload_values(self):
|
|
return view_filter_type_registry.get(self.type).get_preload_values(self)
|
|
|
|
def get_parent(self):
|
|
return self.view
|
|
|
|
|
|
class ViewDecorationManager(models.Manager):
|
|
"""
|
|
Manager for the ViewDecoration model.
|
|
The View can be trashed and the decorations are not deleted, therefore
|
|
we need to filter out the trashed views.
|
|
"""
|
|
|
|
def get_queryset(self):
|
|
trashed_Q = Q(view__trashed=True)
|
|
return super().get_queryset().filter(~trashed_Q)
|
|
|
|
|
|
class ViewDecoration(HierarchicalModelMixin, OrderableMixin, models.Model):
|
|
objects = ViewDecorationManager()
|
|
|
|
view = models.ForeignKey(
|
|
View,
|
|
on_delete=models.CASCADE,
|
|
help_text="The view to which the decoration applies. Each view can have his own "
|
|
"decorations.",
|
|
)
|
|
type = models.CharField(
|
|
max_length=255,
|
|
help_text=(
|
|
"The decorator type. This is then interpreted by the frontend to "
|
|
"display the decoration."
|
|
),
|
|
)
|
|
value_provider_type = models.CharField(
|
|
max_length=255,
|
|
blank=True,
|
|
default="",
|
|
help_text="The value provider type that gives the value to the decorator.",
|
|
)
|
|
value_provider_conf = models.JSONField(
|
|
default=dict,
|
|
help_text="The configuration consumed by the value provider.",
|
|
)
|
|
# The default value is the maximum value of the small integer field because a newly
|
|
# created decoration must always be last.
|
|
order = models.SmallIntegerField(
|
|
default=32767,
|
|
help_text="The position of the decorator has within the view, lowest first. If "
|
|
"there is another decorator with the same order value then the decorator "
|
|
"with the lowest id must be shown first.",
|
|
)
|
|
|
|
@classmethod
|
|
def get_last_order(cls, view):
|
|
queryset = ViewDecoration.objects.filter(view=view)
|
|
return cls.get_highest_order_of_queryset(queryset) + 1
|
|
|
|
def get_parent(self):
|
|
return self.view
|
|
|
|
class Meta:
|
|
ordering = ("order", "id")
|
|
|
|
|
|
class ViewSortManager(models.Manager):
|
|
"""
|
|
Manager for the ViewSort model.
|
|
The View can be trashed and the sorts are not deleted, therefore
|
|
we need to filter out the trashed views.
|
|
"""
|
|
|
|
def get_queryset(self):
|
|
trashed_Q = Q(view__trashed=True) | Q(field__trashed=True)
|
|
return super().get_queryset().filter(~trashed_Q)
|
|
|
|
|
|
class ViewSort(HierarchicalModelMixin, models.Model):
|
|
objects = ViewSortManager()
|
|
|
|
view = models.ForeignKey(
|
|
View,
|
|
on_delete=models.CASCADE,
|
|
help_text="The view to which the sort applies. Each view can have his own "
|
|
"sortings.",
|
|
)
|
|
field = models.ForeignKey(
|
|
"database.Field",
|
|
on_delete=models.CASCADE,
|
|
help_text="The field that must be sorted on.",
|
|
)
|
|
order = models.CharField(
|
|
max_length=4,
|
|
choices=SORT_ORDER_CHOICES,
|
|
help_text="Indicates the sort order direction. ASC (Ascending) is from A to Z "
|
|
"and DESC (Descending) is from Z to A.",
|
|
default=SORT_ORDER_ASC,
|
|
)
|
|
type = models.CharField(
|
|
max_length=32,
|
|
default=DEFAULT_SORT_TYPE_KEY,
|
|
db_default=DEFAULT_SORT_TYPE_KEY,
|
|
help_text=f"Indicates the sort type. Will automatically fall back to `"
|
|
f"{DEFAULT_SORT_TYPE_KEY}` if incompatible with field type.",
|
|
)
|
|
|
|
def get_parent(self):
|
|
return self.view
|
|
|
|
class Meta:
|
|
ordering = ("id",)
|
|
|
|
|
|
class ViewGroupByManager(models.Manager):
|
|
def get_queryset(self):
|
|
trashed_Q = Q(view__trashed=True) | Q(field__trashed=True)
|
|
return super().get_queryset().filter(~trashed_Q)
|
|
|
|
|
|
class ViewGroupBy(HierarchicalModelMixin, models.Model):
|
|
objects = ViewGroupByManager()
|
|
|
|
view = models.ForeignKey(
|
|
View,
|
|
on_delete=models.CASCADE,
|
|
help_text="The view to which the group by applies. Each view can have his own "
|
|
"group bys.",
|
|
)
|
|
field = models.ForeignKey(
|
|
"database.Field",
|
|
on_delete=models.CASCADE,
|
|
help_text="The field that must be grouped by.",
|
|
)
|
|
order = models.CharField(
|
|
max_length=4,
|
|
choices=SORT_ORDER_CHOICES,
|
|
help_text="Indicates the sort order direction. ASC (Ascending) is from A to Z "
|
|
"and DESC (Descending) is from Z to A.",
|
|
default=SORT_ORDER_ASC,
|
|
)
|
|
type = models.CharField(
|
|
max_length=32,
|
|
default=DEFAULT_SORT_TYPE_KEY,
|
|
db_default=DEFAULT_SORT_TYPE_KEY,
|
|
help_text=f"Indicates the sort type. Will automatically fall back to `"
|
|
f"{DEFAULT_SORT_TYPE_KEY}` if incompatible with field type.",
|
|
)
|
|
width = models.PositiveIntegerField(
|
|
default=200,
|
|
help_text="The pixel width of the group by in the related view.",
|
|
)
|
|
|
|
def get_parent(self):
|
|
return self.view
|
|
|
|
class Meta:
|
|
ordering = ("id",)
|
|
|
|
|
|
class GridView(View):
|
|
class RowIdentifierTypes(models.TextChoices):
|
|
ID = "id"
|
|
count = "count"
|
|
|
|
class RowHeightSizes(models.TextChoices):
|
|
small = "small"
|
|
medium = "medium"
|
|
large = "large"
|
|
|
|
# `field_options` is a very misleading name
|
|
# it should probably be more like `fields_with_field_options`
|
|
# since this field will return instances of `Field` not of
|
|
# `GridViewFieldOptions`
|
|
# We might want to change this in the future.
|
|
field_options = models.ManyToManyField(Field, through="GridViewFieldOptions")
|
|
row_identifier_type = models.CharField(
|
|
choices=RowIdentifierTypes.choices, default="id", max_length=10
|
|
)
|
|
row_height_size = models.CharField(
|
|
choices=RowHeightSizes.choices,
|
|
default="small",
|
|
max_length=10,
|
|
db_default="small",
|
|
)
|
|
|
|
|
|
class GridViewFieldOptionsManager(models.Manager):
|
|
"""
|
|
Manager for the GridViewFieldOptions model.
|
|
The View can be trashed and the field options are not deleted, therefore
|
|
we need to filter out the trashed views.
|
|
"""
|
|
|
|
def get_queryset(self):
|
|
trashed_Q = Q(grid_view__trashed=True) | Q(field__trashed=True)
|
|
return super().get_queryset().filter(~trashed_Q)
|
|
|
|
|
|
class GridViewFieldOptions(HierarchicalModelMixin, models.Model):
|
|
objects = GridViewFieldOptionsManager()
|
|
objects_and_trash = models.Manager()
|
|
|
|
grid_view = models.ForeignKey(GridView, on_delete=models.CASCADE)
|
|
field = models.ForeignKey(Field, on_delete=models.CASCADE)
|
|
# The defaults should match the ones in `afterFieldCreated` of the `GridViewType`
|
|
# abstraction in the web-frontend.
|
|
width = models.PositiveIntegerField(
|
|
default=200,
|
|
help_text="The width of the table field in the related view.",
|
|
)
|
|
hidden = models.BooleanField(
|
|
default=False,
|
|
help_text="Whether or not the field should be hidden in the current view.",
|
|
)
|
|
# The default value is the maximum value of the small integer field because a newly
|
|
# created field must always be last.
|
|
order = models.SmallIntegerField(
|
|
default=32767,
|
|
help_text="The position that the field has within the view, lowest first. If "
|
|
"there is another field with the same order value then the field with the "
|
|
"lowest id must be shown first.",
|
|
)
|
|
|
|
aggregation_type = models.CharField(
|
|
default="",
|
|
blank=True,
|
|
max_length=48,
|
|
help_text=(
|
|
"Indicates how the field value is aggregated. This value is "
|
|
"different from the `aggregation_raw_type`. The `aggregation_raw_type` "
|
|
"is the value extracted from "
|
|
"the database, while the `aggregation_type` can implies further "
|
|
"calculations. For example: "
|
|
"if you want to compute an average, `sum` is going to be the "
|
|
"`aggregation_raw_type`, "
|
|
"the value extracted from database, and `sum / row_count` will be the "
|
|
"aggregation result displayed to the user. "
|
|
"This aggregation_type should be used by the client to compute the final "
|
|
"value."
|
|
),
|
|
)
|
|
|
|
aggregation_raw_type = models.CharField(
|
|
default="",
|
|
blank=True,
|
|
max_length=48,
|
|
help_text=(
|
|
"Indicates how to compute the raw aggregation value from database. "
|
|
"This type must be registered in the backend prior to use it."
|
|
),
|
|
)
|
|
|
|
def get_parent(self):
|
|
return self.grid_view
|
|
|
|
class Meta:
|
|
ordering = ("order", "field_id")
|
|
unique_together = ("grid_view", "field")
|
|
|
|
|
|
class GalleryView(View):
|
|
field_options = models.ManyToManyField(Field, through="GalleryViewFieldOptions")
|
|
card_cover_image_field = models.ForeignKey(
|
|
Field,
|
|
blank=True,
|
|
null=True,
|
|
on_delete=models.SET_NULL,
|
|
related_name="gallery_view_card_cover_field",
|
|
help_text="References a file field of which the first image must be shown as "
|
|
"card cover image.",
|
|
)
|
|
|
|
|
|
class GalleryViewFieldOptionsManager(models.Manager):
|
|
"""
|
|
The View can be trashed and the field options are not deleted, therefore
|
|
we need to filter out the trashed views.
|
|
"""
|
|
|
|
def get_queryset(self):
|
|
trashed_Q = Q(gallery_view__trashed=True) | Q(field__trashed=True)
|
|
return super().get_queryset().filter(~trashed_Q)
|
|
|
|
|
|
class GalleryViewFieldOptions(HierarchicalModelMixin, models.Model):
|
|
objects = GalleryViewFieldOptionsManager()
|
|
objects_and_trash = models.Manager()
|
|
|
|
gallery_view = models.ForeignKey(GalleryView, on_delete=models.CASCADE)
|
|
field = models.ForeignKey(Field, on_delete=models.CASCADE)
|
|
hidden = models.BooleanField(
|
|
default=True,
|
|
help_text="Whether or not the field should be hidden in the card.",
|
|
)
|
|
# The default value is the maximum value of the small integer field because a newly
|
|
# created field must always be last.
|
|
order = models.SmallIntegerField(
|
|
default=32767,
|
|
help_text="The order that the field has in the form. Lower value is first.",
|
|
)
|
|
|
|
def get_parent(self):
|
|
return self.gallery_view
|
|
|
|
class Meta:
|
|
ordering = ("order", "field_id")
|
|
unique_together = ("gallery_view", "field")
|
|
|
|
|
|
class FormView(View):
|
|
field_options = models.ManyToManyField(Field, through="FormViewFieldOptions")
|
|
title = models.TextField(
|
|
blank=True,
|
|
help_text="The title that is displayed at the beginning of the form.",
|
|
)
|
|
description = models.TextField(
|
|
blank=True,
|
|
help_text="The description that is displayed at the beginning of the form.",
|
|
)
|
|
mode = models.TextField(
|
|
max_length=64,
|
|
default=lazy(form_view_mode_registry.get_default_choice, str)(),
|
|
choices=lazy(form_view_mode_registry.get_choices, list)(),
|
|
help_text="Configurable mode of the form.",
|
|
)
|
|
cover_image = models.ForeignKey(
|
|
UserFile,
|
|
blank=True,
|
|
null=True,
|
|
on_delete=models.SET_NULL,
|
|
related_name="form_view_cover_image",
|
|
help_text="The user file cover image that is displayed at the top of the form.",
|
|
)
|
|
logo_image = models.ForeignKey(
|
|
UserFile,
|
|
blank=True,
|
|
null=True,
|
|
on_delete=models.SET_NULL,
|
|
related_name="form_view_logo_image",
|
|
help_text="The user file logo image that is displayed at the top of the form.",
|
|
)
|
|
submit_text = models.TextField(
|
|
default=FORM_VIEW_SUBMIT_TEXT,
|
|
help_text="The text displayed on the submit button.",
|
|
)
|
|
submit_action = models.CharField(
|
|
max_length=32,
|
|
choices=FORM_VIEW_SUBMIT_ACTION_CHOICES,
|
|
default=FORM_VIEW_SUBMIT_ACTION_MESSAGE,
|
|
help_text="The action that must be performed after the visitor has filled out "
|
|
"the form.",
|
|
)
|
|
submit_action_message = models.TextField(
|
|
blank=True,
|
|
help_text=f"If the `submit_action` is {FORM_VIEW_SUBMIT_ACTION_MESSAGE}, "
|
|
f"then this message will be shown to the visitor after submitting the form.",
|
|
)
|
|
submit_action_redirect_url = models.URLField(
|
|
blank=True,
|
|
help_text=f"If the `submit_action` is {FORM_VIEW_SUBMIT_ACTION_REDIRECT},"
|
|
f"then the visitors will be redirected to the this URL after submitting the "
|
|
f"form.",
|
|
# Must be kepy in sync with
|
|
# `modules/database/components/view/form/FormViewMetaControls.vue::redirectUrlMaxLength`
|
|
max_length=2000,
|
|
)
|
|
users_to_notify_on_submit = models.ManyToManyField(
|
|
User,
|
|
help_text="The users that must be notified when the form is submitted.",
|
|
)
|
|
|
|
@property
|
|
def active_field_options(self):
|
|
return (
|
|
FormViewFieldOptions.objects.filter(
|
|
form_view=self, enabled=True, field__read_only=False
|
|
)
|
|
.prefetch_related(
|
|
"conditions",
|
|
"condition_groups",
|
|
"allowed_select_options",
|
|
Prefetch("field", queryset=specific_queryset(Field.objects.all())),
|
|
"field__select_options",
|
|
)
|
|
.order_by("order")
|
|
)
|
|
|
|
|
|
class FormViewFieldOptionsManager(models.Manager):
|
|
"""
|
|
The View can be trashed and the field options are not deleted, therefore
|
|
we need to filter out the trashed views.
|
|
"""
|
|
|
|
def get_queryset(self):
|
|
trashed_Q = Q(form_view__trashed=True) | Q(field__trashed=True)
|
|
return super().get_queryset().filter(~trashed_Q)
|
|
|
|
|
|
class FormViewFieldOptions(HierarchicalModelMixin, models.Model):
|
|
objects = FormViewFieldOptionsManager()
|
|
objects_and_trash = models.Manager()
|
|
|
|
form_view = models.ForeignKey(FormView, on_delete=models.CASCADE)
|
|
field = models.ForeignKey(Field, on_delete=models.CASCADE)
|
|
name = models.CharField(
|
|
max_length=255,
|
|
blank=True,
|
|
help_text="By default, the name of the related field will be shown to the "
|
|
"visitor. Optionally another name can be used by setting this name.",
|
|
)
|
|
description = models.TextField(
|
|
blank=True,
|
|
help_text="If provided, then this value be will be shown under the field name.",
|
|
)
|
|
enabled = models.BooleanField(
|
|
default=False, help_text="Indicates whether the field is included in the form."
|
|
)
|
|
required = models.BooleanField(
|
|
default=True,
|
|
help_text="Indicates whether the field is required for the visitor to fill "
|
|
"out.",
|
|
)
|
|
include_all_select_options = models.BooleanField(
|
|
default=True,
|
|
db_default=True,
|
|
help_text="Indicates whether all fields must be included. Only works if the "
|
|
"related field type can have select options.",
|
|
)
|
|
allowed_select_options = models.ManyToManyField(
|
|
SelectOption,
|
|
through="FormViewFieldOptionsAllowedSelectOptions",
|
|
help_text="If `include_all_select_options` is True, then only these select "
|
|
"options can be chosen.",
|
|
)
|
|
show_when_matching_conditions = models.BooleanField(
|
|
default=False,
|
|
help_text="Indicates whether this field is visible when the conditions are "
|
|
"met.",
|
|
)
|
|
condition_type = models.CharField(
|
|
max_length=3,
|
|
choices=FILTER_TYPES,
|
|
default=FILTER_TYPE_AND,
|
|
help_text="Indicates whether all (AND) or any (OR) of the conditions should "
|
|
"match before shown.",
|
|
)
|
|
field_component = models.CharField(
|
|
max_length=32,
|
|
default=DEFAULT_FORM_VIEW_FIELD_COMPONENT_KEY,
|
|
help_text="Indicates which field input component is used in the form. The "
|
|
"value is only used in the frontend, and can differ per field.",
|
|
)
|
|
# The default value is the maximum value of the small integer field because a newly
|
|
# created field must always be last.
|
|
order = models.SmallIntegerField(
|
|
default=32767,
|
|
help_text="The order that the field has in the form. Lower value is first.",
|
|
)
|
|
|
|
def get_parent(self):
|
|
return self.form_view
|
|
|
|
class Meta:
|
|
ordering = ("order", "field_id")
|
|
unique_together = ("form_view", "field")
|
|
|
|
def is_required(self):
|
|
return (
|
|
self.required
|
|
# If the field is only visible when conditions are met, we can't do a
|
|
# required backend validation because there is no way of knowing whether
|
|
# the provided values match the conditions in the backend.
|
|
and (
|
|
not self.show_when_matching_conditions
|
|
or len(self.conditions.all()) == 0
|
|
)
|
|
)
|
|
|
|
|
|
class FormViewFieldOptionsAllowedSelectOptions(models.Model):
|
|
form_view_field_options = models.ForeignKey(
|
|
FormViewFieldOptions, on_delete=models.CASCADE, related_name="+"
|
|
)
|
|
select_option = models.ForeignKey(
|
|
SelectOption,
|
|
on_delete=models.CASCADE,
|
|
related_name="+",
|
|
)
|
|
|
|
|
|
class FormViewFieldOptionsConditionManager(models.Manager):
|
|
def get_queryset(self):
|
|
return super().get_queryset().filter(~Q(field__trashed=True))
|
|
|
|
|
|
class FormViewFieldOptionsConditionGroup(HierarchicalModelMixin, FilterGroupMixin):
|
|
field_option = models.ForeignKey(
|
|
FormViewFieldOptions,
|
|
on_delete=models.CASCADE,
|
|
help_text="The form view option where the condition is related to.",
|
|
related_name="condition_groups",
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ("id",)
|
|
|
|
def get_parent(self):
|
|
return self.field_option
|
|
|
|
|
|
class FormViewFieldOptionsCondition(HierarchicalModelMixin, models.Model):
|
|
field_option = models.ForeignKey(
|
|
FormViewFieldOptions,
|
|
on_delete=models.CASCADE,
|
|
help_text="The form view option where the condition is related to.",
|
|
related_name="conditions",
|
|
)
|
|
field = models.ForeignKey(
|
|
"database.Field",
|
|
on_delete=models.CASCADE,
|
|
help_text="The field of which the value must be compared to the filter value.",
|
|
)
|
|
type = models.CharField(
|
|
max_length=48,
|
|
help_text="Indicates how the field's value must be compared to the filter's "
|
|
"value. The filter is always in this order `field` `type` `value` "
|
|
"(example: `field_1` `contains` `Test`).",
|
|
)
|
|
value = models.TextField(
|
|
blank=True,
|
|
help_text="The filter value that must be compared to the field's value.",
|
|
)
|
|
group = models.ForeignKey(
|
|
FormViewFieldOptionsConditionGroup,
|
|
on_delete=models.CASCADE,
|
|
null=True,
|
|
related_name="conditions",
|
|
)
|
|
objects = FormViewFieldOptionsConditionManager()
|
|
|
|
def get_parent(self):
|
|
return self.field_option
|
|
|
|
class Meta:
|
|
ordering = ("id",)
|
|
|
|
|
|
class ViewRows(CreatedAndUpdatedOnMixin, models.Model):
|
|
view = models.OneToOneField(View, on_delete=models.CASCADE, related_name="rows")
|
|
row_ids = ArrayField(
|
|
models.PositiveIntegerField(),
|
|
default=list,
|
|
help_text="The rows that are shown in the view. This list can be used by webhooks "
|
|
"to determine which rows have been changed since the last check.",
|
|
)
|
|
|
|
@classmethod
|
|
def create_missing_for_views(cls, views: list[View], model=None):
|
|
"""
|
|
Creates ViewRows objects for the given views if they don't already exist.
|
|
|
|
:param views: The views for which to create ViewRows objects.
|
|
"""
|
|
|
|
from baserow.contrib.database.views.handler import ViewHandler
|
|
|
|
existing_view_ids = ViewRows.objects.filter(view__in=views).values_list(
|
|
"view_id", flat=True
|
|
)
|
|
view_map = {view.id: view for view in views}
|
|
missing_view_ids = list(set(view_map.keys()) - set(existing_view_ids))
|
|
|
|
view_rows = []
|
|
for view_id in missing_view_ids:
|
|
view = view_map[view_id]
|
|
row_ids = (
|
|
ViewHandler()
|
|
.get_queryset(view, model=model, apply_sorts=False)
|
|
.values_list("id", flat=True)
|
|
)
|
|
view_rows.append(ViewRows(view=view, row_ids=list(row_ids)))
|
|
|
|
return ViewRows.objects.bulk_create(view_rows, ignore_conflicts=True)
|
|
|
|
def get_diff(self, model=None):
|
|
"""
|
|
Executes the view query and returns the current row IDs in the view,
|
|
along with the differences between the current state and the last saved state.
|
|
"""
|
|
|
|
from baserow.contrib.database.views.handler import ViewHandler
|
|
|
|
rows = ViewHandler().get_queryset(self.view, model=model, apply_sorts=False)
|
|
previous_row_ids = set(self.row_ids)
|
|
new_row_ids = set(rows.order_by().values_list("id", flat=True))
|
|
|
|
row_ids_entered = new_row_ids - previous_row_ids
|
|
row_ids_exited = previous_row_ids - new_row_ids
|
|
|
|
return list(new_row_ids), list(row_ids_entered), list(row_ids_exited)
|
|
|
|
|
|
class ViewSubscription(models.Model):
|
|
view = models.ForeignKey(View, on_delete=models.CASCADE, related_name="subscribers")
|
|
subscriber_content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
|
subscriber_id = models.PositiveIntegerField()
|
|
subscriber = GenericForeignKey("subscriber_content_type", "subscriber_id")
|
|
|
|
class Meta:
|
|
unique_together = ("view", "subscriber_content_type", "subscriber_id")
|