1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-14 00:59:06 +00:00
bramw_baserow/backend/src/baserow/contrib/database/views/models.py
2025-04-01 14:19:16 +00:00

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")