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

Resolve "Receive notification on form submission"

This commit is contained in:
Davide Silvestri 2023-11-23 09:25:54 +00:00
parent 37764dfd98
commit d1702e40b1
43 changed files with 1299 additions and 236 deletions
backend
changelog/entries/unreleased/feature
premium
backend
src/baserow_premium/api/views
tests/baserow_premium_tests
web-frontend/modules/baserow_premium/components/views/form
web-frontend/modules

View file

@ -242,6 +242,7 @@ def validate_body_custom_fields(
type_attribute_name="type",
partial=False,
allow_empty_type=False,
return_validated=False,
):
"""
This decorator can validate the request data dynamically using the generated
@ -293,6 +294,7 @@ def validate_body_custom_fields(
type_attribute_name=type_attribute_name,
partial=partial,
allow_empty_type=allow_empty_type,
return_validated=return_validated,
)
return func(*args, **kwargs)

View file

@ -206,8 +206,8 @@ def validate_data_custom_fields(
base_serializer_class: Optional[Type[ModelSerializer]] = None,
type_attribute_name: str = "type",
partial: bool = False,
return_validated: bool = False,
allow_empty_type: bool = False,
return_validated: bool = False,
) -> Dict[str, Any]:
"""
Validates the provided data with the serializer generated by the registry based on
@ -222,6 +222,7 @@ def validate_data_custom_fields(
:param type_attribute_name: The attribute key name that contains the type value.
:param partial: Whether the data is a partial update.
:param allow_empty_type: Whether the type can be empty.
:param return_validated: Returns validated_data from DRF serializer.
:raises RequestBodyValidationException: When the type is not a valid choice.
:return: The validated data.
"""

View file

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-10-06 15:56+0000\n"
"POT-Creation-Date: 2023-11-16 11:09+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -18,17 +18,17 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: src/baserow/contrib/builder/application_types.py:59
#: src/baserow/contrib/builder/application_types.py:66
msgid "Homepage"
msgstr ""
#: src/baserow/contrib/builder/data_sources/service.py:125
#: src/baserow/contrib/builder/data_sources/service.py:128
msgid "Data source"
msgstr ""
#: src/baserow/contrib/builder/elements/element_types.py:718
#: src/baserow/contrib/builder/elements/element_types.py:719
#: src/baserow/contrib/builder/elements/element_types.py:720
#: src/baserow/contrib/builder/elements/element_types.py:166
#: src/baserow/contrib/builder/elements/element_types.py:167
#: src/baserow/contrib/builder/elements/element_types.py:168
#, python-format
msgid "Column %(count)s"
msgstr ""

View file

@ -142,3 +142,32 @@ class FormViewSubmittedSerializer(serializers.ModelSerializer):
"submit_action_redirect_url",
"row_id",
)
class FormViewNotifyOnSubmitSerializerMixin(serializers.Serializer):
receive_notification_on_submit = serializers.SerializerMethodField(
help_text="A boolean indicating if the current user should be notified when "
"the form is submitted."
)
@extend_schema_field(OpenApiTypes.BOOL)
def get_receive_notification_on_submit(self, obj):
logged_user_id = self.context["user"].id
for usr in obj.users_to_notify_on_submit.all():
if usr.id == logged_user_id:
return True
return False
def to_internal_value(self, data):
ret = super().to_internal_value(data)
receive_notification = data.get("receive_notification_on_submit", None)
if receive_notification is not None:
if not isinstance(receive_notification, bool):
raise serializers.ValidationError(
"The value must be a boolean indicating if the current user should be "
"notified on submit or not."
)
ret["receive_notification_on_submit"] = receive_notification
return ret

View file

@ -448,15 +448,20 @@ class UpdateViewSerializer(serializers.ModelSerializer):
"password from the view and make it publicly accessible again.",
)
def to_representation(self, data):
representation = super().to_representation(data)
public_view_password = representation.pop("public_view_password", None)
if public_view_password is not None:
# Pass a differently named attribute down to the handler, so it knows
# the difference between the user setting a new raw password and/or
# someone directly changing the literal hashed and salted password value.
representation["raw_public_view_password"] = public_view_password
return representation
def to_internal_value(self, data):
internal_value = super().to_internal_value(data)
# Store the hashed password in the database if provided. An empty string
# means that the password protection is disabled.
raw_public_view_password = internal_value.pop("public_view_password", None)
if raw_public_view_password is not None or "public" in data:
internal_value["public_view_password"] = (
View.make_password(raw_public_view_password)
if raw_public_view_password
else "" # nosec b105
)
return internal_value
def validate_ownership_type(self, value):
try:

View file

@ -302,10 +302,11 @@ class ViewsView(APIView):
views_by_type[type(view)].append(view)
serialized_views = []
for view_type, views in views_by_type.items():
for _, views in views_by_type.items():
serialized_views += view_type_registry.get_serializer(
views,
ViewSerializer,
context={"user": request.user},
filters=filters,
sortings=sortings,
decorations=decorations,
@ -365,7 +366,10 @@ class ViewsView(APIView):
)
@transaction.atomic
@validate_body_custom_fields(
view_type_registry, base_serializer_class=CreateViewSerializer, partial=True
view_type_registry,
base_serializer_class=CreateViewSerializer,
partial=True,
return_validated=True,
)
@map_exceptions(
{
@ -399,6 +403,7 @@ class ViewsView(APIView):
serializer = view_type_registry.get_serializer(
view,
ViewSerializer,
context={"user": request.user},
filters=filters,
sortings=sortings,
decorations=decorations,
@ -465,6 +470,7 @@ class ViewView(APIView):
sortings=sortings,
decorations=decorations,
group_bys=group_bys,
context={"user": request.user},
)
return Response(serializer.data)
@ -542,6 +548,7 @@ class ViewView(APIView):
request.data,
base_serializer_class=UpdateViewSerializer,
partial=True,
return_validated=True,
)
with view_type.map_api_exceptions():
@ -556,6 +563,7 @@ class ViewView(APIView):
sortings=sortings,
decorations=decorations,
group_bys=group_bys,
context={"user": request.user},
)
return Response(serializer.data)
@ -659,6 +667,7 @@ class DuplicateViewView(APIView):
sortings=True,
decorations=True,
group_bys=True,
context={"user": request.user},
)
return Response(serializer.data)
@ -1850,7 +1859,9 @@ class RotateViewSlugView(APIView):
ViewHandler().get_view_for_update(request.user, view_id).specific,
)
serializer = view_type_registry.get_serializer(view, ViewSerializer)
serializer = view_type_registry.get_serializer(
view, ViewSerializer, context={"user": request.user}
)
return Response(serializer.data)

View file

@ -750,9 +750,13 @@ class DatabaseConfig(AppConfig):
from baserow.contrib.database.fields.notification_types import (
CollaboratorAddedToRowNotificationType,
)
from baserow.contrib.database.views.notification_types import (
FormSubmittedNotificationType,
)
from baserow.core.notifications.registries import notification_type_registry
notification_type_registry.register(CollaboratorAddedToRowNotificationType())
notification_type_registry.register(FormSubmittedNotificationType())
# The signals must always be imported last because they use the registries
# which need to be filled first.

View file

@ -1726,6 +1726,9 @@ class LinkRowFieldType(FieldType):
)
)
def serialize_to_input_value(self, field: Field, value: any) -> any:
return [v.id for v in value.all()]
def _get_related_model_and_primary_field(self, instance):
"""
Returns related model and primary field.
@ -3413,6 +3416,9 @@ class MultipleSelectFieldType(SelectOptionBaseFieldType):
return ", ".join(export_value)
def serialize_to_input_value(self, field: Field, value: any) -> any:
return [v.id for v in value.all()]
def get_model_field(self, instance, **kwargs):
return None

View file

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-08-07 15:27+0000\n"
"POT-Creation-Date: 2023-11-16 11:09+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -49,7 +49,7 @@ msgid ""
"\"%(airtable_share_id)s\""
msgstr ""
#: src/baserow/contrib/database/application_types.py:210
#: src/baserow/contrib/database/application_types.py:214
msgid "Table"
msgstr ""
@ -123,8 +123,8 @@ msgstr ""
#: src/baserow/contrib/database/plugins.py:72
#: src/baserow/contrib/database/plugins.py:94
#: src/baserow/contrib/database/table/handler.py:342
#: src/baserow/contrib/database/table/handler.py:355
#: src/baserow/contrib/database/table/handler.py:375
#: src/baserow/contrib/database/table/handler.py:388
msgid "Name"
msgstr ""
@ -133,13 +133,13 @@ msgid "Last name"
msgstr ""
#: src/baserow/contrib/database/plugins.py:74
#: src/baserow/contrib/database/table/handler.py:343
#: src/baserow/contrib/database/table/handler.py:376
msgid "Notes"
msgstr ""
#: src/baserow/contrib/database/plugins.py:75
#: src/baserow/contrib/database/plugins.py:96
#: src/baserow/contrib/database/table/handler.py:344
#: src/baserow/contrib/database/table/handler.py:377
msgid "Active"
msgstr ""
@ -221,20 +221,20 @@ msgstr ""
msgid "Row (%(row_id)s) moved"
msgstr ""
#: src/baserow/contrib/database/rows/actions.py:584
#: src/baserow/contrib/database/rows/actions.py:585
msgid "Update row"
msgstr ""
#: src/baserow/contrib/database/rows/actions.py:584
#: src/baserow/contrib/database/rows/actions.py:585
#, python-format
msgid "Row (%(row_id)s) updated"
msgstr ""
#: src/baserow/contrib/database/rows/actions.py:687
#: src/baserow/contrib/database/rows/actions.py:685
msgid "Update rows"
msgstr ""
#: src/baserow/contrib/database/rows/actions.py:687
#: src/baserow/contrib/database/rows/actions.py:685
#, python-format
msgid "Rows (%(row_ids)s) updated"
msgstr ""
@ -287,11 +287,11 @@ msgid ""
"\"%(original_table_name)s\" (%(original_table_id)s) "
msgstr ""
#: src/baserow/contrib/database/table/handler.py:246
#: src/baserow/contrib/database/table/handler.py:279
msgid "Grid"
msgstr ""
#: src/baserow/contrib/database/table/handler.py:304
#: src/baserow/contrib/database/table/handler.py:337
#, python-format
msgid "Field %d"
msgstr ""
@ -346,149 +346,213 @@ msgstr ""
msgid "The Database Token \"%(token_name)s\" (%(token_id)s) has been deleted"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:39
#: src/baserow/contrib/database/views/actions.py:42
msgid "Create a view filter"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:40
#: src/baserow/contrib/database/views/actions.py:43
#, python-format
msgid "View filter created on field \"%(field_name)s\" (%(field_id)s)"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:133
#: src/baserow/contrib/database/views/actions.py:143
msgid "Update a view filter"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:134
#: src/baserow/contrib/database/views/actions.py:144
#, python-format
msgid "View filter updated on field \"%(field_name)s\" (%(field_id)s)"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:255
#: src/baserow/contrib/database/views/actions.py:265
msgid "Delete a view filter"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:256
#: src/baserow/contrib/database/views/actions.py:266
#, python-format
msgid "View filter deleted from field \"%(field_name)s\" (%(field_id)s)"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:352
#: src/baserow/contrib/database/views/actions.py:366
msgid "Create a view filter group"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:367
msgid "View filter group created"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:443
msgid "Update a view filter group"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:444
#, python-format
msgid "View filter group updated to \"%(filter_type)s\""
msgstr ""
#: src/baserow/contrib/database/views/actions.py:536
msgid "Delete a view filter group"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:537
msgid "View filter group deleted"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:649
msgid "Create a view sort"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:353
#: src/baserow/contrib/database/views/actions.py:650
#, python-format
msgid "View sorted on field \"%(field_name)s\" (%(field_id)s)"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:429
#: src/baserow/contrib/database/views/actions.py:726
msgid "Update a view sort"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:430
#: src/baserow/contrib/database/views/actions.py:727
#, python-format
msgid "View sort updated on field \"%(field_name)s\" (%(field_id)s)"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:528
#: src/baserow/contrib/database/views/actions.py:825
msgid "Delete a view sort"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:529
#: src/baserow/contrib/database/views/actions.py:826
#, python-format
msgid "View sort deleted from field \"%(field_name)s\" (%(field_id)s)"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:606
#: src/baserow/contrib/database/views/actions.py:903
msgid "Order views"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:606
#: src/baserow/contrib/database/views/actions.py:903
msgid "Views order changed"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:673
#: src/baserow/contrib/database/views/actions.py:970
msgid "Update view field options"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:674
#: src/baserow/contrib/database/views/actions.py:971
msgid "ViewFieldOptions updated"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:769
#: src/baserow/contrib/database/views/actions.py:1066
msgid "View slug URL updated"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:770
#: src/baserow/contrib/database/views/actions.py:1067
msgid "View changed public slug URL"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:839
#: src/baserow/contrib/database/views/actions.py:1136
msgid "Update view"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:840
#: src/baserow/contrib/database/views/actions.py:1137
#, python-format
msgid "View \"%(view_name)s\" (%(view_id)s) updated"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:914
#: src/baserow/contrib/database/views/actions.py:1213
msgid "Create view"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:915
#: src/baserow/contrib/database/views/actions.py:1214
#, python-format
msgid "View \"%(view_name)s\" (%(view_id)s) created"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:983
#: src/baserow/contrib/database/views/actions.py:1282
msgid "Duplicate view"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:985
#: src/baserow/contrib/database/views/actions.py:1284
#, python-format
msgid ""
"View \"%(view_name)s\" (%(view_id)s) duplicated from view "
"\"%(original_view_name)s\" (%(original_view_id)s)"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1053
#: src/baserow/contrib/database/views/actions.py:1352
msgid "Delete view"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1054
#: src/baserow/contrib/database/views/actions.py:1353
#, python-format
msgid "View \"%(view_name)s\" (%(view_id)s) deleted"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1111
#: src/baserow/contrib/database/views/actions.py:1410
msgid "Create decoration"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1112
#: src/baserow/contrib/database/views/actions.py:1411
#, python-format
msgid "View decoration %(decorator_id)s created"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1207
#: src/baserow/contrib/database/views/actions.py:1506
msgid "Update decoration"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1208
#: src/baserow/contrib/database/views/actions.py:1507
#, python-format
msgid "View decoration %(decorator_id)s updated"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1332
#: src/baserow/contrib/database/views/actions.py:1631
msgid "Delete decoration"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1333
#: src/baserow/contrib/database/views/actions.py:1632
#, python-format
msgid "View decoration %(decorator_id)s deleted"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1717
msgid "Create a view group"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1718
#, python-format
msgid "View grouped on field \"%(field_name)s\" (%(field_id)s)"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1794
msgid "Update a view group"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1795
#, python-format
msgid "View group by updated on field \"%(field_name)s\" (%(field_id)s)"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1897
msgid "Delete a view group"
msgstr ""
#: src/baserow/contrib/database/views/actions.py:1898
#, python-format
msgid "View group by deleted from field \"%(field_name)s\" (%(field_id)s)"
msgstr ""
#: src/baserow/contrib/database/views/notification_types.py:83
#, python-format
msgid "%(form_name)s has been submitted in %(table_name)s"
msgstr ""
#: src/baserow/contrib/database/views/notification_types.py:100
#, python-format
msgid "and 1 more field"
msgid_plural "and %(count)s more fields"
msgstr[0] ""
msgstr[1] ""
#: src/baserow/contrib/database/webhooks/actions.py:20
msgid "Create Webhook"
msgstr ""

View file

@ -0,0 +1,22 @@
# Generated by Django 3.2.21 on 2023-11-03 13:20
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("database", "0140_lastmodifiedbyfield"),
]
operations = [
migrations.AddField(
model_name="formview",
name="users_to_notify_on_submit",
field=models.ManyToManyField(
help_text="The users that must be notified when the form is submitted.",
to=settings.AUTH_USER_MODEL,
),
),
]

View file

@ -20,7 +20,7 @@ from typing import (
from django.contrib.auth.models import AbstractUser
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import Q, QuerySet
from django.db.models import Model, Q, QuerySet
from django.db.models.fields.related import ForeignKey, ManyToManyField
from django.utils.encoding import force_str
@ -770,12 +770,11 @@ class RowHandler(metaclass=baserow_trace_methods(tracer)):
rows_created_counter.add(1)
m2m_change_tracker = RowM2MChangeTracker()
for name, value in manytomany_values.items():
field_object = model.get_field_object(name)
m2m_change_tracker.track_m2m_created_for_new_row(
instance, field_object["field"], value
for field_name, value in manytomany_values.items():
m2m_objects, _ = self._prepare_m2m_field_related_objects(
instance, field_name, value
)
getattr(instance, name).set(value)
getattr(instance, field_name).through.objects.bulk_create(m2m_objects)
fields = []
update_collector = FieldUpdateCollector(table, starting_row_ids=[instance.id])
@ -1110,42 +1109,10 @@ class RowHandler(metaclass=baserow_trace_methods(tracer)):
for index, row in enumerate(inserted_rows):
_, manytomany_values = rows_relationships[index]
for field_name, value in manytomany_values.items():
through = getattr(model, field_name).through
through_fields = through._meta.get_fields()
value_column = None
row_column = None
model_field = model._meta.get_field(field_name)
is_referencing_the_same_table = (
model_field.model == model_field.related_model
m2m_objects, _ = self._prepare_m2m_field_related_objects(
row, field_name, value
)
# Figure out which field in the many to many through table holds the row
# value and which one contains the value.
for field in through_fields:
if type(field) is not ForeignKey:
continue
if is_referencing_the_same_table:
# django creates 'from_tableXmodel' and 'to_tableXmodel'
# columns for self-referencing many_to_many relations.
row_column = field.get_attname_column()[1]
value_column = row_column.replace("from", "to")
break
elif field.remote_field.model == model:
row_column = field.get_attname_column()[1]
else:
value_column = field.get_attname_column()[1]
for i in value:
many_to_many[field_name].append(
getattr(model, field_name).through(
**{
row_column: row.id,
value_column: i,
}
)
)
many_to_many[field_name].extend(m2m_objects)
field_object = model.get_field_object(field_name)
m2m_change_tracker.track_m2m_created_for_new_row(
@ -1230,6 +1197,52 @@ class RowHandler(metaclass=baserow_trace_methods(tracer)):
return inserted_rows, report
return rows_to_return
def _prepare_m2m_field_related_objects(
self, row: GeneratedTableModel, field_name: str, value: List[Any]
) -> Tuple[List[Type[Model]], str]:
"""
Prepares the many to many related objects for a given row and field
name, taking into account whether the field is self-referencing or not.
:param row: The row instance for which the related objects must be
prepared.
:param field_name: The name of the field for which the related objects
must be prepared.
:param value: The value of the field.
:return: A list of related objects and a string indicating the column
name of the row in the through table.
"""
model = row._meta.model
through = getattr(model, field_name).through
through_fields = through._meta.get_fields()
value_column = None
row_column = None
model_field = model._meta.get_field(field_name)
is_referencing_the_same_table = model_field.model == model_field.related_model
# Figure out which field in the many to many through table holds the row
# value and which one contains the value.
for field in through_fields:
if type(field) is not ForeignKey:
continue
if is_referencing_the_same_table:
# django creates 'from_tableXmodel' and 'to_tableXmodel'
# columns for self-referencing many_to_many relations.
row_column = field.get_attname_column()[1]
value_column = row_column.replace("from", "to")
break
elif field.remote_field.model == model:
row_column = field.get_attname_column()[1]
else:
value_column = field.get_attname_column()[1]
return [
through(**{row_column: row.id, value_column: v}) for v in value
], row_column
def validate_rows(
self,
table: Table,
@ -1638,7 +1651,7 @@ class RowHandler(metaclass=baserow_trace_methods(tracer)):
)
many_to_many = defaultdict(list)
row_column_name = None
row_column_names: Dict[str, str] = {}
row_ids_change_m2m_per_field = defaultdict(set)
# This update can remove link row connections with other rows. We need to keep
@ -1651,36 +1664,7 @@ class RowHandler(metaclass=baserow_trace_methods(tracer)):
for index, row in enumerate(rows_to_update):
manytomany_values = rows_relationships[index]
for field_name, value in manytomany_values.items():
through = getattr(model, field_name).through
through_fields = through._meta.get_fields()
value_column = None
row_column = None
model_field = model._meta.get_field(field_name)
is_referencing_the_same_table = (
model_field.model == model_field.related_model
)
# Figure out which field in the many to many through table holds the row
# value and which one contains the value.
for field in through_fields:
if type(field) is not ForeignKey:
continue
row_ids_change_m2m_per_field[field_name].add(row.id)
if is_referencing_the_same_table:
# django creates 'from_tableXmodel' and 'to_tableXmodel'
# columns for self-referencing many_to_many relations.
row_column = field.get_attname_column()[1]
row_column_name = row_column
value_column = row_column.replace("from", "to")
break
elif field.remote_field.model == model:
row_column = field.get_attname_column()[1]
row_column_name = row_column
else:
value_column = field.get_attname_column()[1]
row_ids_change_m2m_per_field[field_name].add(row.id)
# If this m2m field is a link row we need to find out all connections
# which will be removed by this update. This is so we can update
@ -1691,24 +1675,22 @@ class RowHandler(metaclass=baserow_trace_methods(tracer)):
field, field_name, row, value
)
m2m_objects, row_column_name = self._prepare_m2m_field_related_objects(
row, field_name, value
)
row_column_names[field_name] = row_column_name
if len(value) == 0:
many_to_many[field_name].append(None)
else:
for i in value:
many_to_many[field_name].append(
getattr(model, field_name).through(
**{
row_column: row.id,
value_column: i,
}
)
)
many_to_many[field_name].extend(m2m_objects)
# The many to many relations need to be updated first because they need to
# exist when the rows are updated in bulk. Otherwise, the formula and lookup
# fields can't see the relations.
for field_name, values in many_to_many.items():
through = getattr(model, field_name).through
row_column_name = row_column_names[field_name]
filters = {
f"{row_column_name}__in": row_ids_change_m2m_per_field[field_name]
}

View file

@ -100,6 +100,8 @@ from baserow.core.utils import (
find_unused_name,
get_model_reference_field_name,
set_allowed_attrs,
set_allowed_m2m_fields,
split_attrs_and_m2m_fields,
)
from .exceptions import (
@ -143,6 +145,7 @@ from .registries import (
view_type_registry,
)
from .signals import (
form_submitted,
view_created,
view_decoration_created,
view_decoration_deleted,
@ -532,7 +535,14 @@ class ViewHandler(metaclass=baserow_trace_methods(tracer)):
if limit:
views = views[:limit]
views = specific_iterator(views)
views = specific_iterator(
views,
per_content_type_queryset_hook=(
lambda model, queryset: view_type_registry.get_by_model(
model
).enhance_queryset(queryset)
),
)
return views
def list_workspace_views(
@ -927,8 +937,10 @@ class ViewHandler(metaclass=baserow_trace_methods(tracer)):
changed_allowed_keys.add(ownership_type_key)
previous_public_value = view.public
view = set_allowed_attrs(view_values, allowed_fields, view)
allowed_attrs, allowed_m2m_fields = split_attrs_and_m2m_fields(
allowed_fields, view
)
view = set_allowed_attrs(view_values, allowed_attrs, view)
if previous_public_value != view.public:
CoreHandler().check_permissions(
user,
@ -938,6 +950,7 @@ class ViewHandler(metaclass=baserow_trace_methods(tracer)):
)
view.save()
view = set_allowed_m2m_fields(view_values, allowed_m2m_fields, view)
new_view_values = self._get_prepared_values_for_data(
view_type, view, changed_allowed_keys
@ -3041,7 +3054,11 @@ class ViewHandler(metaclass=baserow_trace_methods(tracer)):
raise ValidationError(field_errors)
allowed_values = extract_allowed(values, allowed_field_names)
return RowHandler().force_create_row(user, table, allowed_values, model)
created_row = RowHandler().force_create_row(user, table, allowed_values, model)
form_submitted.send(
self, form=form, row=created_row, values=allowed_values, user=user
)
return created_row
def get_public_views_row_checker(
self,

View file

@ -730,6 +730,10 @@ class FormView(View):
f"then the visitors will be redirected to the this URL after submitting the "
f"form.",
)
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):

View file

@ -0,0 +1,125 @@
from dataclasses import asdict, dataclass
from typing import Any, Dict
from django.contrib.auth.models import AnonymousUser
from django.dispatch import receiver
from django.utils.translation import gettext as _
from django.utils.translation import ngettext
from baserow.contrib.database.views.operations import UpdateViewOperationType
from baserow.core.handler import CoreHandler
from baserow.core.notifications.handler import NotificationHandler
from baserow.core.notifications.registries import (
EmailNotificationTypeMixin,
NotificationType,
)
from .signals import form_submitted
@dataclass
class FormSubmittedNotificationData:
form_id: int
form_name: str
table_id: int
table_name: str
database_id: int
row_id: int
values: Dict[str, Any]
class FormSubmittedNotificationType(EmailNotificationTypeMixin, NotificationType):
type = "form_submitted"
@classmethod
def create_form_submitted_notification(
cls, form, row, values, users_to_notify, sender=None
):
if not users_to_notify:
return
model = row._meta.model
human_readable_values = []
for form_field_option in form.formviewfieldoptions_set.order_by("order", "id"):
field_instance = form_field_option.field
field_name = field_instance.db_column
if field_name not in values:
continue
field_object = model.get_field_object(field_name)
field_type = field_object["type"]
field_instance = field_object["field"]
human_readable_field_value = field_type.get_human_readable_value(
getattr(row, field_name), field_object
)
human_readable_values.append(
[field_instance.name, human_readable_field_value]
)
# if the user is anonymous, we don't have a username to display in the
# notification, so we set it to None
if isinstance(sender, AnonymousUser):
sender = None
notification_data = FormSubmittedNotificationData(
form.id,
form.name,
form.table.id,
form.table.name,
form.table.database_id,
row.id,
human_readable_values,
)
return NotificationHandler.create_direct_notification_for_users(
notification_type=cls.type,
recipients=users_to_notify,
sender=sender,
data=asdict(notification_data),
workspace=form.table.database.workspace,
)
@classmethod
def get_notification_title_for_email(cls, notification, context):
return _("%(form_name)s has been submitted in %(table_name)s") % {
"form_name": notification.data["form_name"],
"table_name": notification.data["table_name"],
}
@classmethod
def get_notification_description_for_email(cls, notification, context):
limit_values = 3
value_summary = "\n\n".join(
[
"%s: %s" % (key, value)
for key, value in notification.data["values"][:limit_values]
]
)
missing_fields = max(0, len(notification.data["values"]) - limit_values)
if missing_fields > 0:
value_summary += "\n\n\n" + ngettext(
"and 1 more field",
"and %(count)s more fields",
missing_fields,
) % {
"count": missing_fields,
}
return value_summary
@receiver(form_submitted)
def create_form_submitted_notification(sender, form, row, values, user, **kwargs):
users_to_notify = form.users_to_notify_on_submit.all()
if not users_to_notify:
return
# Ensure all users still have permissions on the table to see the notification
allowed_users = CoreHandler().check_permission_for_multiple_actors(
users_to_notify,
UpdateViewOperationType.type,
workspace=form.table.database.workspace,
context=form,
)
FormSubmittedNotificationType.create_form_submitted_notification(
form, row, values, allowed_users, sender=user
)

View file

@ -37,7 +37,10 @@ from baserow.core.registry import (
ModelRegistryMixin,
Registry,
)
from baserow.core.utils import get_model_reference_field_name
from baserow.core.utils import (
get_model_reference_field_name,
split_attrs_and_m2m_fields,
)
from .exceptions import (
AggregationTypeAlreadyRegistered,
@ -599,17 +602,6 @@ class ViewType(
:return: The updates values.
"""
from baserow.contrib.database.views.models import View
raw_public_view_password = values.get("raw_public_view_password", None)
if raw_public_view_password is not None:
if raw_public_view_password:
values["public_view_password"] = View.make_password(
raw_public_view_password
)
else:
values["public_view_password"] = "" # nosec b105
return values
def view_created(self, view: "View"):
@ -702,7 +694,14 @@ class ViewType(
"show_logo": view.show_logo,
}
values.update({key: getattr(view, key) for key in self.allowed_fields})
allowed_attrs, m2m_fields = split_attrs_and_m2m_fields(
self.allowed_fields, view
)
values.update({key: getattr(view, key) for key in allowed_attrs})
values.update(
{key: [item.id for item in getattr(view, key).all()] for key in m2m_fields}
)
return values

View file

@ -10,6 +10,7 @@ view_created = Signal()
view_updated = Signal()
view_deleted = Signal()
views_reordered = Signal()
form_submitted = Signal()
view_filter_created = Signal()
view_filter_updated = Signal()

View file

@ -8,7 +8,7 @@ from django.core.files.storage import Storage
from django.db.models import Count, Q
from django.urls import include, path
from rest_framework.serializers import PrimaryKeyRelatedField
from rest_framework import serializers
from baserow.api.user_files.serializers import UserFileField
from baserow.contrib.database.api.fields.errors import ERROR_FIELD_NOT_IN_TABLE
@ -21,6 +21,7 @@ from baserow.contrib.database.api.views.form.exceptions import (
)
from baserow.contrib.database.api.views.form.serializers import (
FormViewFieldOptionsSerializer,
FormViewNotifyOnSubmitSerializerMixin,
)
from baserow.contrib.database.api.views.gallery.serializers import (
GalleryViewFieldOptionsSerializer,
@ -323,7 +324,7 @@ class GalleryViewType(ViewType):
field_options_allowed_fields = ["hidden", "order"]
serializer_field_names = ["card_cover_image_field"]
serializer_field_overrides = {
"card_cover_image_field": PrimaryKeyRelatedField(
"card_cover_image_field": serializers.PrimaryKeyRelatedField(
queryset=FileField.objects.all(),
required=False,
default=None,
@ -534,6 +535,7 @@ class FormViewType(ViewType):
"submit_action",
"submit_action_message",
"submit_action_redirect_url",
"users_to_notify_on_submit",
]
field_options_allowed_fields = [
"name",
@ -545,6 +547,7 @@ class FormViewType(ViewType):
"order",
"field_component",
]
serializer_mixins = [FormViewNotifyOnSubmitSerializerMixin]
serializer_field_names = [
"title",
"description",
@ -555,6 +558,7 @@ class FormViewType(ViewType):
"submit_action",
"submit_action_message",
"submit_action_redirect_url",
"receive_notification_on_submit", # from FormViewNotifyOnSubmitSerializerMixin
]
serializer_field_overrides = {
"cover_image": UserFileField(
@ -1075,6 +1079,17 @@ class FormViewType(ViewType):
mode_type = form_view_mode_registry.get(values.get("mode", view.mode))
mode_type.before_form_update(values, view, user)
notify_on_submit = values.pop("receive_notification_on_submit", None)
if notify_on_submit is not None:
users_to_notify_on_submit = [
utn.id
for utn in view.users_to_notify_on_submit.all()
if utn.id != user.id
]
if notify_on_submit:
users_to_notify_on_submit.append(user.id)
values["users_to_notify_on_submit"] = users_to_notify_on_submit
def prepare_values(
self, values: Dict[str, Any], table: Table, user: AbstractUser
) -> Dict[str, Any]:
@ -1130,7 +1145,9 @@ class FormViewType(ViewType):
return values
def enhance_queryset(self, queryset):
return queryset.prefetch_related("formviewfieldoptions_set")
return queryset.prefetch_related(
"formviewfieldoptions_set", "users_to_notify_on_submit"
)
def enhance_field_options_queryset(self, queryset):
return queryset.prefetch_related("conditions", "condition_groups")

View file

@ -17,7 +17,7 @@ from baserow.ws.registries import page_registry
from baserow.ws.tasks import broadcast_to_users
def generate_view_created_payload(view):
def generate_view_created_payload(user, view):
payload = {
"type": "view_created",
"view": view_type_registry.get_serializer(
@ -27,6 +27,7 @@ def generate_view_created_payload(view):
sortings=True,
decorations=True,
group_bys=True,
context={"user": user},
).data,
}
return payload
@ -77,7 +78,7 @@ def broadcast_to(user, view, payload):
@receiver(view_signals.view_created)
def view_created(sender, view, user, **kwargs):
payload = generate_view_created_payload(view)
payload = generate_view_created_payload(user, view)
broadcast_to(user, view, payload)
@ -131,7 +132,7 @@ def broadcast_to_users_ownership_change(user, new_view, old_view, payload):
# the view.
transaction.on_commit(
lambda: table_page_type.broadcast(
generate_view_created_payload(new_view),
generate_view_created_payload(user, new_view),
getattr(user, "web_socket_id", None),
table_id=new_view.table_id,
exclude_user_ids=old_view_user_ids,
@ -160,6 +161,7 @@ def view_updated(sender, view, old_view, user, **kwargs):
sortings=False,
decorations=False,
group_bys=False,
context={"user": user},
).data,
}

View file

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-08-07 15:27+0000\n"
"POT-Creation-Date: 2023-11-16 11:09+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -202,12 +202,12 @@ msgstr ""
msgid "You have %(count)d new notifications - Baserow"
msgstr ""
#: src/baserow/core/notification_types.py:91
#: src/baserow/core/notification_types.py:94
#, python-format
msgid "%(user)s accepted your invitation to collaborate to %(workspace_name)s."
msgstr ""
#: src/baserow/core/notification_types.py:132
#: src/baserow/core/notification_types.py:135
#, python-format
msgid "%(user)s rejected your invitation to collaborate to %(workspace_name)s."
msgstr ""
@ -246,70 +246,70 @@ msgid ""
"\"%(application_name)s\" (%(application_id)s)."
msgstr ""
#: src/baserow/core/templates/baserow/core/group_invitation.html:146
#: src/baserow/core/templates/baserow/core/workspace_invitation.html:146
#: src/baserow/core/templates/baserow/core/group_invitation.html:144
#: src/baserow/core/templates/baserow/core/workspace_invitation.html:144
msgid "Invitation"
msgstr ""
#: src/baserow/core/templates/baserow/core/group_invitation.html:151
#: src/baserow/core/templates/baserow/core/group_invitation.html:149
#, python-format
msgid ""
"<strong>%(first_name)s</strong> has invited you to collaborate on <strong>"
"%(group_name)s</strong>."
msgstr ""
#: src/baserow/core/templates/baserow/core/group_invitation.html:165
#: src/baserow/core/templates/baserow/core/workspace_invitation.html:169
#: src/baserow/core/templates/baserow/core/group_invitation.html:163
#: src/baserow/core/templates/baserow/core/workspace_invitation.html:167
msgid "Accept invitation"
msgstr ""
#: src/baserow/core/templates/baserow/core/group_invitation.html:179
#: src/baserow/core/templates/baserow/core/notifications_summary.html:214
#: src/baserow/core/templates/baserow/core/user/account_deleted.html:156
#: src/baserow/core/templates/baserow/core/user/account_deletion_cancelled.html:156
#: src/baserow/core/templates/baserow/core/user/account_deletion_scheduled.html:161
#: src/baserow/core/templates/baserow/core/user/reset_password.html:179
#: src/baserow/core/templates/baserow/core/workspace_invitation.html:183
#: src/baserow/core/templates/baserow/core/group_invitation.html:177
#: src/baserow/core/templates/baserow/core/notifications_summary.html:212
#: src/baserow/core/templates/baserow/core/user/account_deleted.html:154
#: src/baserow/core/templates/baserow/core/user/account_deletion_cancelled.html:154
#: src/baserow/core/templates/baserow/core/user/account_deletion_scheduled.html:159
#: src/baserow/core/templates/baserow/core/user/reset_password.html:177
#: src/baserow/core/templates/baserow/core/workspace_invitation.html:181
msgid ""
"Baserow is an open source no-code database tool which allows you to "
"collaborate on projects, customers and more. It gives you the powers of a "
"developer without leaving your browser."
msgstr ""
#: src/baserow/core/templates/baserow/core/notifications_summary.html:148
#: src/baserow/core/templates/baserow/core/notifications_summary.html:146
#, python-format
msgid "You have %(counter)s new notification"
msgid_plural "You have %(counter)s new notifications"
msgstr[0] ""
msgstr[1] ""
#: src/baserow/core/templates/baserow/core/notifications_summary.html:189
#: src/baserow/core/templates/baserow/core/notifications_summary.html:187
#, python-format
msgid "Plus %(counter)s more notification."
msgid_plural "Plus %(counter)s more notifications."
msgstr[0] ""
msgstr[1] ""
#: src/baserow/core/templates/baserow/core/notifications_summary.html:200
#: src/baserow/core/templates/baserow/core/notifications_summary.html:198
msgid "View in Baserow"
msgstr ""
#: src/baserow/core/templates/baserow/core/user/account_deleted.html:146
#: src/baserow/core/templates/baserow/core/user/account_deleted.html:144
msgid "Account permanently deleted"
msgstr ""
#: src/baserow/core/templates/baserow/core/user/account_deleted.html:151
#: src/baserow/core/templates/baserow/core/user/account_deleted.html:149
#, python-format
msgid ""
"Your account (%(username)s) on Baserow (%(public_web_frontend_hostname)s) "
"has been permanently deleted."
msgstr ""
#: src/baserow/core/templates/baserow/core/user/account_deletion_cancelled.html:146
#: src/baserow/core/templates/baserow/core/user/account_deletion_cancelled.html:144
msgid "Account deletion cancelled"
msgstr ""
#: src/baserow/core/templates/baserow/core/user/account_deletion_cancelled.html:151
#: src/baserow/core/templates/baserow/core/user/account_deletion_cancelled.html:149
#, python-format
msgid ""
"Your account (%(username)s) on Baserow (%(public_web_frontend_hostname)s) "
@ -317,29 +317,29 @@ msgid ""
"cancelled."
msgstr ""
#: src/baserow/core/templates/baserow/core/user/account_deletion_scheduled.html:146
#: src/baserow/core/templates/baserow/core/user/account_deletion_scheduled.html:144
msgid "Account pending deletion"
msgstr ""
#: src/baserow/core/templates/baserow/core/user/account_deletion_scheduled.html:151
#: src/baserow/core/templates/baserow/core/user/account_deletion_scheduled.html:149
#, python-format
msgid ""
"Your account (%(username)s) on Baserow (%(public_web_frontend_hostname)s) "
"will be permanently deleted in %(days_left)s days."
msgstr ""
#: src/baserow/core/templates/baserow/core/user/account_deletion_scheduled.html:156
#: src/baserow/core/templates/baserow/core/user/account_deletion_scheduled.html:154
msgid ""
"If you've changed your mind and want to cancel your account deletion, you "
"just have to login again."
msgstr ""
#: src/baserow/core/templates/baserow/core/user/reset_password.html:146
#: src/baserow/core/templates/baserow/core/user/reset_password.html:165
#: src/baserow/core/templates/baserow/core/user/reset_password.html:144
#: src/baserow/core/templates/baserow/core/user/reset_password.html:163
msgid "Reset password"
msgstr ""
#: src/baserow/core/templates/baserow/core/user/reset_password.html:151
#: src/baserow/core/templates/baserow/core/user/reset_password.html:149
#, python-format
msgid ""
"A password reset was requested for your account (%(username)s) on Baserow "
@ -347,7 +347,7 @@ msgid ""
"simply ignore this email."
msgstr ""
#: src/baserow/core/templates/baserow/core/user/reset_password.html:156
#: src/baserow/core/templates/baserow/core/user/reset_password.html:154
#, python-format
msgid ""
"To continue with your password reset, simply click the button below, and you "
@ -355,7 +355,7 @@ msgid ""
"hours."
msgstr ""
#: src/baserow/core/templates/baserow/core/workspace_invitation.html:151
#: src/baserow/core/templates/baserow/core/workspace_invitation.html:149
#, python-format
msgid ""
"<strong>%(first_name)s</strong> has invited you to collaborate on <strong>"
@ -411,62 +411,62 @@ msgstr ""
msgid "User \"%(user_email)s\" (%(user_id)s) updated"
msgstr ""
#: src/baserow/core/user/actions.py:162
#: src/baserow/core/user/actions.py:163
msgid "Schedule user deletion"
msgstr ""
#: src/baserow/core/user/actions.py:164
#: src/baserow/core/user/actions.py:165
#, python-format
msgid ""
"User \"%(user_email)s\" (%(user_id)s) scheduled to be deleted after grace "
"time"
msgstr ""
#: src/baserow/core/user/actions.py:195
#: src/baserow/core/user/actions.py:196
msgid "Cancel user deletion"
msgstr ""
#: src/baserow/core/user/actions.py:197
#: src/baserow/core/user/actions.py:198
#, python-format
msgid ""
"User \"%(user_email)s\" (%(user_id)s) logged in cancelling the deletion "
"process"
msgstr ""
#: src/baserow/core/user/actions.py:228
#: src/baserow/core/user/actions.py:229
msgid "Sign In User"
msgstr ""
#: src/baserow/core/user/actions.py:230
#: src/baserow/core/user/actions.py:231
#, python-format
msgid ""
"User \"%(user_email)s\" (%(user_id)s) signed in via \"%(auth_provider_type)s"
"\" (%(auth_provider_id)s) auth provider"
msgstr ""
#: src/baserow/core/user/actions.py:282
#: src/baserow/core/user/actions.py:283
msgid "Send reset user password"
msgstr ""
#: src/baserow/core/user/actions.py:283
#: src/baserow/core/user/actions.py:284
#, python-format
msgid "User \"%(user_email)s\" (%(user_id)s) requested to reset password"
msgstr ""
#: src/baserow/core/user/actions.py:313
#: src/baserow/core/user/actions.py:314
msgid "Change user password"
msgstr ""
#: src/baserow/core/user/actions.py:314
#: src/baserow/core/user/actions.py:315
#, python-format
msgid "User \"%(user_email)s\" (%(user_id)s) changed password"
msgstr ""
#: src/baserow/core/user/actions.py:350
#: src/baserow/core/user/actions.py:351
msgid "Reset user password"
msgstr ""
#: src/baserow/core/user/actions.py:351
#: src/baserow/core/user/actions.py:352
#, python-format
msgid "User \"%(user_email)s\" (%(user_id)s) reset password"
msgstr ""
@ -487,7 +487,7 @@ msgstr ""
msgid "Account deletion cancelled - Baserow"
msgstr ""
#: src/baserow/core/user/handler.py:217
#: src/baserow/core/user/handler.py:248
#, python-format
msgid "%(name)s's workspace"
msgstr ""

View file

@ -107,10 +107,12 @@ class NotificationHandler:
@classmethod
@baserow_trace(tracer)
def all_notifications_for_user(cls, user, workspace: Optional[Workspace] = None):
def all_notifications_for_user(
cls, user, include_workspace: Optional[Workspace] = None
):
workspace_filter = Q(workspace_id=None)
if workspace:
workspace_filter |= Q(workspace_id=workspace.id)
if include_workspace is not None:
workspace_filter |= Q(workspace_id=include_workspace.id)
direct = Q(broadcast=False, recipient=user, queued=False) & workspace_filter
uncleared_broadcast = Q(broadcast=True, recipient=user, cleared=False)
@ -581,6 +583,10 @@ class NotificationHandler:
**kwargs,
)
# Prefetch the user profiles if possible to avoid N+1 queries later
if isinstance(recipients, QuerySet) and issubclass(recipients.model, User):
recipients = recipients.select_related("profile")
notification_recipients = NotificationRecipient.objects.bulk_create(
[
cls.construct_notification_recipient(

View file

@ -167,8 +167,6 @@ class CustomFieldsInstanceMixin:
else:
field_names = self.serializer_field_names
mixins = [] if request_serializer else self.serializer_mixins
# Prepend the word "Request" to the ref name, so that when this serializer is
# generated for the request and response, it doesn't result in a name conflict.
if request_serializer and meta_ref_name is None:
@ -180,7 +178,7 @@ class CustomFieldsInstanceMixin:
self.model_class,
field_names,
field_overrides=field_overrides,
base_mixins=mixins,
base_mixins=self.serializer_mixins,
meta_extra_kwargs=self.serializer_extra_kwargs,
meta_ref_name=meta_ref_name,
base_class=base_class,

View file

@ -13,10 +13,10 @@ from decimal import Decimal
from fractions import Fraction
from itertools import islice
from numbers import Number
from typing import Dict, Iterable, List, Optional, Tuple, Union
from typing import Dict, Iterable, List, Optional, Tuple, Type, Union
from django.db import transaction
from django.db.models import ForeignKey
from django.db.models import ForeignKey, ManyToManyField, Model
from django.db.models.fields import NOT_PROVIDED
from django.db.transaction import get_connection
@ -43,6 +43,43 @@ RE_PROP_NAME = re.compile(
)
def split_attrs_and_m2m_fields(
field_names: List[str], instance: Type[Model]
) -> Tuple[List[str], List[str]]:
"""
Separates the provided field names into attributes and m2m fields. The attributes
can be set directly on the instance using set_allowed_attrs while the m2m fields
need to be set using the set_allowed_m2m_fields function.
"""
attrs, m2m_fields = [], []
for field_name in field_names:
field = instance._meta.get_field(field_name)
if isinstance(field, ManyToManyField):
m2m_fields.append(field_name)
else:
attrs.append(field_name)
return attrs, m2m_fields
def set_allowed_m2m_fields(values, allowed_fields, instance):
"""
Sets the attributes of the instance with the values of the key names that are in the
allowed_fields. The other keys are ignored. This function is specifically for
ManyToManyFields.
Notice that this function will make a update query to the database for each
ManyToManyField that needs to be updated. This is because the ManyToManyField
cannot be updated directly on the instance.
"""
for field in allowed_fields:
if field in values:
getattr(instance, field).set(values[field])
return instance
def extract_allowed(values, allowed_fields):
"""
Returns a new dict with the values of the key names that are in the allowed_fields.

View file

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-08-07 15:27+0000\n"
"POT-Creation-Date: 2023-11-16 11:09+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -22,6 +22,6 @@ msgstr ""
msgid "Token contained no recognizable user identification"
msgstr ""
#: src/baserow/api/authentication.py:31
#: src/baserow/api/authentication.py:33
msgid "User not found"
msgstr ""

View file

@ -80,6 +80,8 @@ def setup_interesting_test_table(
if database is None:
database = data_fixture.create_database_application(user=user)
file_suffix = file_suffix or ""
try:
user2, user3 = User.objects.filter(
email__in=["user2@example.com", "user3@example.com"]
@ -294,7 +296,6 @@ def setup_interesting_test_table(
other_table_primary_decimal_field.id: None,
},
)
file_suffix = file_suffix or ""
with freeze_time("2020-01-01 12:00"):
user_file_1 = data_fixture.create_user_file(
original_name=f"name{file_suffix}.txt",

View file

@ -0,0 +1,41 @@
import pytest
from baserow.contrib.database.action.scopes import ViewActionScopeType
from baserow.contrib.database.views.actions import UpdateViewActionType
from baserow.core.action.handler import ActionHandler
from baserow.core.action.registries import action_type_registry
from baserow.test_utils.helpers import assert_undo_redo_actions_are_valid
@pytest.mark.django_db
@pytest.mark.undo_redo
def test_can_undo_update_view_receive_notification_on_submit(data_fixture):
session_id = "session-id"
user = data_fixture.create_user(session_id=session_id)
table = data_fixture.create_database_table(user=user)
form_view = data_fixture.create_form_view(table=table)
def list_users_to_notify_on_submit():
return [u.id for u in form_view.users_to_notify_on_submit.all()]
assert list_users_to_notify_on_submit() == []
action_type_registry.get_by_type(UpdateViewActionType).do(
user, form_view, receive_notification_on_submit=True
)
assert list_users_to_notify_on_submit() == [user.id]
undone_actions = ActionHandler.undo(
user, [ViewActionScopeType.value(form_view.id)], session_id
)
assert_undo_redo_actions_are_valid(undone_actions, [UpdateViewActionType])
assert list_users_to_notify_on_submit() == []
redone_actions = ActionHandler.redo(
user, [ViewActionScopeType.value(form_view.id)], session_id
)
assert_undo_redo_actions_are_valid(redone_actions, [UpdateViewActionType])
assert list_users_to_notify_on_submit() == [user.id]

View file

@ -2611,3 +2611,80 @@ def test_submit_empty_form_view_for_interesting_test_table(api_client, data_fixt
assert response.status_code == HTTP_200_OK, response.json()
response_json = response.json()
assert response_json["submit_action"] == "MESSAGE"
@pytest.mark.django_db
def test_user_can_update_form_to_receive_notification(api_client, data_fixture):
user_1, token_1 = data_fixture.create_user_and_token()
user_2, token_2 = data_fixture.create_user_and_token()
workspace = data_fixture.create_workspace(users=[user_1, user_2])
database = data_fixture.create_database_application(workspace=workspace)
table = data_fixture.create_database_table(user=user_1, database=database)
form = data_fixture.create_form_view(table=table, public=True)
url = reverse("api:database:views:item", kwargs={"view_id": form.id})
# users_to_notify cannot be set directly
api_client.patch(
url,
{"users_to_notify_on_submit": [user_1.id]},
format="json",
HTTP_AUTHORIZATION=f"JWT {token_1}",
)
assert form.users_to_notify_on_submit.count() == 0
# The only way is via receive_notification_on_submit for the requesting user
response = api_client.patch(
url,
{"receive_notification_on_submit": True},
format="json",
HTTP_AUTHORIZATION=f"JWT {token_1}",
)
assert response.status_code == HTTP_200_OK
assert response.json()["receive_notification_on_submit"] is True
assert form.users_to_notify_on_submit.count() == 1
response = api_client.get(
reverse("api:database:tables:item", kwargs={"table_id": form.table_id}),
format="json",
HTTP_AUTHORIZATION=f"JWT {token_1}",
)
assert response.status_code == HTTP_200_OK
# another user sees the notification setting as false
response = api_client.get(url, format="json", HTTP_AUTHORIZATION=f"JWT {token_2}")
assert response.status_code == HTTP_200_OK
assert response.json()["receive_notification_on_submit"] is False
@pytest.mark.django_db()
def test_loading_form_views_does_not_increase_the_number_of_queries(
api_client, data_fixture
):
user, token = data_fixture.create_user_and_token()
workspace = data_fixture.create_workspace(user=user)
database = data_fixture.create_database_application(user=user, workspace=workspace)
table = data_fixture.create_database_table(database=database)
data_fixture.create_form_view(table=table, public=True)
with CaptureQueriesContext(connection) as captured_1:
api_client.get(
reverse("api:database:views:list", kwargs={"table_id": table.id}),
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
data_fixture.create_form_view(table=table, public=True)
data_fixture.create_form_view(table=table, public=True)
data_fixture.create_form_view(table=table, public=True)
with CaptureQueriesContext(connection) as captured_2:
api_client.get(
reverse("api:database:views:list", kwargs={"table_id": table.id}),
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert len(captured_1) == len(captured_2)

View file

@ -1962,7 +1962,7 @@ def test_public_view_row_checker_runs_expected_queries_on_init(
view=public_grid_view, field=filtered_field, type="equal", value="FilterValue"
)
model = table.get_model()
num_queries = 7
num_queries = 8
with django_assert_num_queries(num_queries):
# First query to get the public views, second query to get their filters.
ViewHandler().get_public_views_row_checker(

View file

@ -0,0 +1,367 @@
from unittest.mock import patch
from django.shortcuts import reverse
import pytest
from freezegun import freeze_time
from pytest_unordered import unordered
from rest_framework.status import HTTP_200_OK
from baserow.contrib.database.views.handler import ViewHandler
from baserow.contrib.database.views.notification_types import (
FormSubmittedNotificationType,
)
from baserow.core.models import WorkspaceUser
from baserow.test_utils.helpers import AnyInt, setup_interesting_test_table
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.tasks.broadcast_to_users.apply")
def test_user_receive_notification_on_form_submit(
mocked_broadcast_to_users, api_client, data_fixture
):
user_1, token_1 = data_fixture.create_user_and_token(email="test1@test.nl")
user_2, token_2 = data_fixture.create_user_and_token(email="test2@test.nl")
workspace = data_fixture.create_workspace(members=[user_1, user_2])
database = data_fixture.create_database_application(
user=user_1, workspace=workspace
)
table = data_fixture.create_database_table(name="Example", database=database)
text_field = data_fixture.create_text_field(name="text", table=table)
number_field = data_fixture.create_number_field(name="number", table=table)
form = data_fixture.create_form_view(table=table, public=True)
data_fixture.create_form_view_field_option(
form, text_field, required=True, enabled=True, order=1
)
data_fixture.create_form_view_field_option(
form, number_field, required=False, enabled=True, order=2
)
def submit_form():
response = api_client.post(
reverse("api:database:views:form:submit", kwargs={"slug": form.slug}),
{
f"field_{text_field.id}": "Valid",
f"field_{number_field.id}": 0,
},
format="json",
)
assert response.status_code == HTTP_200_OK
return response
# by default no users are notified on form submit
submit_form()
assert mocked_broadcast_to_users.call_count == 0
# Set the form to notify user_1 on submit
response = api_client.patch(
reverse("api:database:views:item", kwargs={"view_id": form.id}),
{"receive_notification_on_submit": True},
format="json",
HTTP_AUTHORIZATION=f"JWT {token_1}",
)
assert response.status_code == HTTP_200_OK
assert form.users_to_notify_on_submit.count() == 1
# now user_1 should receive the notification
with freeze_time("2021-01-01 12:00"):
submit_form()
expected_notification = {
"id": AnyInt(),
"type": "form_submitted",
"sender": None,
"workspace": {"id": workspace.id},
"created_on": "2021-01-01T12:00:00Z",
"read": False,
"data": {
"row_id": AnyInt(),
"form_id": form.id,
"form_name": form.name,
"table_id": table.id,
"table_name": table.name,
"database_id": database.id,
"values": [
["text", "Valid"],
["number", "0"],
],
},
}
# the notification can be retrieved via the api and received via websockets
response = api_client.get(
reverse("api:notifications:list", kwargs={"workspace_id": workspace.id}),
HTTP_AUTHORIZATION=f"JWT {token_1}",
)
assert response.status_code == HTTP_200_OK
assert response.json() == {
"count": 1,
"next": None,
"previous": None,
"results": [expected_notification],
}
assert mocked_broadcast_to_users.call_count == 1
args = mocked_broadcast_to_users.call_args_list[0][0]
assert args[0] == [
[user_1.id],
{
"type": "notifications_created",
"notifications": [expected_notification],
},
]
# user_2 received nothing
response = api_client.get(
reverse("api:notifications:list", kwargs={"workspace_id": workspace.id}),
HTTP_AUTHORIZATION=f"JWT {token_2}",
)
assert response.status_code == HTTP_200_OK
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.tasks.broadcast_to_users.apply")
def test_all_interested_users_receive_the_notification_on_form_submit(
mocked_broadcast_to_users, api_client, data_fixture
):
user_1 = data_fixture.create_user(email="test1@test.nl")
user_2 = data_fixture.create_user(email="test2@test.nl")
user_3 = data_fixture.create_user(email="test3@test.nl")
user_4 = data_fixture.create_user(email="test4@test.nl")
workspace = data_fixture.create_workspace(members=[user_1, user_2, user_3, user_4])
database = data_fixture.create_database_application(
user=user_1, workspace=workspace
)
table = data_fixture.create_database_table(name="Example", database=database)
text_field = data_fixture.create_text_field(name="text", table=table)
number_field = data_fixture.create_number_field(name="number", table=table)
form = data_fixture.create_form_view(table=table, public=True)
data_fixture.create_form_view_field_option(
form, text_field, required=True, enabled=True, order=1
)
data_fixture.create_form_view_field_option(
form, number_field, required=False, enabled=True, order=2
)
form.users_to_notify_on_submit.add(user_1, user_2, user_3, user_4)
def submit_form():
response = api_client.post(
reverse("api:database:views:form:submit", kwargs={"slug": form.slug}),
{
f"field_{text_field.id}": "Valid",
f"field_{number_field.id}": 0,
},
format="json",
)
assert response.status_code == HTTP_200_OK
return response
submit_form()
# all the user will receive the notification
assert mocked_broadcast_to_users.call_count == 1
args = mocked_broadcast_to_users.call_args_list[0][0]
assert unordered(args[0][0], [user_1.id, user_2.id, user_3.id, user_4.id])
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.tasks.broadcast_to_users.apply")
def test_only_users_with_access_to_the_table_receive_the_notification_on_form_submit(
mocked_broadcast_to_users, api_client, data_fixture
):
user_1 = data_fixture.create_user(email="test1@test.nl")
user_2 = data_fixture.create_user(email="test2@test.nl")
workspace = data_fixture.create_workspace(members=[user_1, user_2])
database = data_fixture.create_database_application(
user=user_1, workspace=workspace
)
table = data_fixture.create_database_table(name="Example", database=database)
text_field = data_fixture.create_text_field(name="text", table=table)
number_field = data_fixture.create_number_field(name="number", table=table)
form = data_fixture.create_form_view(table=table, public=True)
data_fixture.create_form_view_field_option(
form, text_field, required=True, enabled=True, order=1
)
data_fixture.create_form_view_field_option(
form, number_field, required=False, enabled=True, order=2
)
form.users_to_notify_on_submit.add(user_1, user_2)
def submit_form():
response = api_client.post(
reverse("api:database:views:form:submit", kwargs={"slug": form.slug}),
{
f"field_{text_field.id}": "Valid",
f"field_{number_field.id}": 0,
},
format="json",
)
assert response.status_code == HTTP_200_OK
return response
submit_form()
assert mocked_broadcast_to_users.call_count == 1
args = mocked_broadcast_to_users.call_args_list[0][0]
assert unordered(args[0][0], [user_1.id, user_2.id])
mocked_broadcast_to_users.reset_mock()
# user_2 should not receive the notification because he has no access to the table
WorkspaceUser.objects.filter(
user__in=[user_1, user_2], workspace=workspace
).delete()
submit_form()
assert mocked_broadcast_to_users.call_count == 0
# If a user regain access to the table, he should receive the notification again
WorkspaceUser.objects.create(user=user_1, workspace=workspace, order=1)
submit_form()
assert mocked_broadcast_to_users.call_count == 1
args = mocked_broadcast_to_users.call_args_list[0][0]
assert args[0][0] == [user_1.id]
@pytest.mark.django_db(transaction=True)
def test_form_submit_notification_can_be_render_as_email(api_client, data_fixture):
user = data_fixture.create_user()
workspace = data_fixture.create_workspace(user=user)
database = data_fixture.create_database_application(user=user, workspace=workspace)
table = data_fixture.create_database_table(name="Example", database=database)
text_field = data_fixture.create_text_field(name="text", table=table)
number_field = data_fixture.create_number_field(name="number", table=table)
form = data_fixture.create_form_view(table=table, public=True)
data_fixture.create_form_view_field_option(
form, text_field, required=True, enabled=True, order=1
)
data_fixture.create_form_view_field_option(
form, number_field, required=False, enabled=True, order=2
)
form_values = {f"field_{text_field.id}": "Valid", f"field_{number_field.id}": 0}
row = ViewHandler().submit_form_view(user, form, form_values)
notification_recipients = (
FormSubmittedNotificationType.create_form_submitted_notification(
form, row, form_values, [user]
)
)
notification = notification_recipients[0].notification
assert FormSubmittedNotificationType.get_notification_title_for_email(
notification, {}
) == "%(form_name)s has been submitted in %(table_name)s" % {
"form_name": notification.data["form_name"],
"table_name": notification.data["table_name"],
}
assert (
FormSubmittedNotificationType.get_notification_description_for_email(
notification, {}
)
== "text: Valid\n\nnumber: 0"
)
# Add two more fields to the form
text_field_2 = data_fixture.create_text_field(name="text 2", table=table)
number_field_2 = data_fixture.create_number_field(name="number 2", table=table)
data_fixture.create_form_view_field_option(
form, text_field_2, required=True, enabled=True, order=3
)
data_fixture.create_form_view_field_option(
form, number_field_2, required=False, enabled=True, order=4
)
form_values = {
f"field_{text_field.id}": "Valid",
f"field_{number_field.id}": 0,
f"field_{text_field_2.id}": "Valid 2",
f"field_{number_field_2.id}": 0,
}
row = ViewHandler().submit_form_view(user, form, form_values)
notification_recipients = (
FormSubmittedNotificationType.create_form_submitted_notification(
form, row, form_values, [user]
)
)
notification = notification_recipients[0].notification
assert FormSubmittedNotificationType.get_notification_title_for_email(
notification, {}
) == "%(form_name)s has been submitted in %(table_name)s" % {
"form_name": notification.data["form_name"],
"table_name": notification.data["table_name"],
}
assert (
FormSubmittedNotificationType.get_notification_description_for_email(
notification, {}
)
== "text: Valid\n\nnumber: 0\n\ntext 2: Valid 2\n\n\nand 1 more field"
)
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.tasks.broadcast_to_users.apply")
def test_can_user_receive_notification_for_all_interesting_field_values(
mocked_broadcast_to_users, api_client, data_fixture
):
user = data_fixture.create_user()
workspace = data_fixture.create_workspace(user=user)
database = data_fixture.create_database_application(user=user, workspace=workspace)
table, user, row, _, _ = setup_interesting_test_table(
data_fixture, user, database=database
)
form = data_fixture.create_form_view(table=table, public=True)
form.users_to_notify_on_submit.add(user)
model = table.get_model()
form_values = {}
expected_values = []
for i, field_object in enumerate(model.get_field_objects(), start=1):
field_instance = field_object["field"]
field_type = field_object["type"]
field_name = field_object["name"]
if field_object["type"].can_be_in_form_view:
data_fixture.create_form_view_field_option(
form, field_instance, required=True, enabled=True, order=i
)
input_value = field_type.serialize_to_input_value(
field_instance, getattr(row, field_name)
)
form_values[field_name] = input_value
human_readable_value = field_type.get_human_readable_value(
getattr(row, field_name), field_object
)
expected_values.append([field_instance.name, human_readable_value])
row = ViewHandler().submit_form_view(None, form, form_values)
assert row is not None
assert mocked_broadcast_to_users.call_count == 1
notification = mocked_broadcast_to_users.call_args[0][0][1]["notifications"][0]
assert notification["data"]["values"] == expected_values

View file

@ -200,20 +200,23 @@ def test_specific_iterator_with_prefetch_related(
filter_2 = data_fixture.create_view_filter(view=gallery_view)
filter_3 = data_fixture.create_view_filter(view=gallery_view)
base_queryset = View.objects.filter(
id__in=[
grid_view.id,
gallery_view.id,
]
).prefetch_related("viewfilter_set")
base_queryset = (
View.objects.filter(
id__in=[
grid_view.id,
gallery_view.id,
]
)
.order_by("id")
.prefetch_related("viewfilter_set")
)
with django_assert_num_queries(4):
specific_objects = list(specific_iterator(base_queryset))
all_1 = specific_objects[0].viewfilter_set.all()
assert all_1[0].id == filter_1.id
assert [f.id for f in all_1] == [filter_1.id]
all_2 = specific_objects[1].viewfilter_set.all()
assert all_2[0].id == filter_2.id
assert all_2[1].id == filter_3.id
assert [f.id for f in all_2] == [filter_2.id, filter_3.id]
@pytest.mark.django_db

View file

@ -0,0 +1,7 @@
{
"type": "feature",
"message": "Send a notification on form submission to subscribed users.",
"issue_number": 2054,
"bullet_points": [],
"created_at": "2023-11-11"
}

View file

@ -95,7 +95,6 @@ class PremiumViewAttributesView(APIView):
)
serializer = view_type_registry.get_serializer(
view,
ViewSerializer,
view, ViewSerializer, context={"user": request.user}
)
return Response(serializer.data)

View file

@ -8,10 +8,14 @@ from rest_framework.status import (
HTTP_402_PAYMENT_REQUIRED,
)
from baserow.contrib.database.views.handler import ViewHandler
from baserow.contrib.database.views.registries import view_type_registry
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_premium_view_attributes_view(api_client, premium_data_fixture):
@pytest.mark.parametrize("view_type", view_type_registry.registry.keys())
def test_premium_view_attributes_view(view_type, api_client, premium_data_fixture):
user, token = premium_data_fixture.create_user_and_token(
email="test@test.nl",
password="password",
@ -19,7 +23,9 @@ def test_premium_view_attributes_view(api_client, premium_data_fixture):
has_active_premium_license=True,
)
table = premium_data_fixture.create_database_table(user=user)
view = premium_data_fixture.create_grid_view(table=table)
view = ViewHandler().create_view(
user=user, table=table, type_name=view_type, name=view_type
)
response = api_client.patch(
reverse(

View file

@ -0,0 +1,76 @@
from unittest.mock import patch
from django.shortcuts import reverse
from django.test.utils import override_settings
import pytest
from baserow_premium.views.models import OWNERSHIP_TYPE_PERSONAL
from pytest_unordered import unordered
from rest_framework.status import HTTP_200_OK
from baserow.contrib.database.views.handler import ViewHandler
@pytest.mark.django_db(transaction=True)
@override_settings(DEBUG=True)
@patch("baserow.ws.tasks.broadcast_to_users.apply")
def test_user_stop_receiving_notification_if_another_user_change_view_ownership(
mocked_broadcast_to_users, api_client, premium_data_fixture
):
user_1 = premium_data_fixture.create_user(
has_active_premium_license=True,
)
user_2 = premium_data_fixture.create_user(
has_active_premium_license=True,
)
workspace = premium_data_fixture.create_workspace(members=[user_1, user_2])
database = premium_data_fixture.create_database_application(
user=user_1, workspace=workspace
)
table = premium_data_fixture.create_database_table(
name="Example", database=database
)
text_field = premium_data_fixture.create_text_field(name="text", table=table)
number_field = premium_data_fixture.create_number_field(name="number", table=table)
form = premium_data_fixture.create_form_view(table=table, public=True)
premium_data_fixture.create_form_view_field_option(
form, text_field, required=True, enabled=True, order=1
)
premium_data_fixture.create_form_view_field_option(
form, number_field, required=False, enabled=True, order=2
)
form.users_to_notify_on_submit.add(user_1, user_2)
def submit_form():
response = api_client.post(
reverse("api:database:views:form:submit", kwargs={"slug": form.slug}),
{
f"field_{text_field.id}": "Valid",
f"field_{number_field.id}": 0,
},
format="json",
)
assert response.status_code == HTTP_200_OK
return response
submit_form()
assert mocked_broadcast_to_users.call_count == 1
args = mocked_broadcast_to_users.call_args_list[0][0]
assert unordered(args[0][0], [user_1.id, user_2.id])
# user_2 change the ownership of the view to personal
ViewHandler().update_view(
user=user_2, view=form, ownership_type=OWNERSHIP_TYPE_PERSONAL
)
mocked_broadcast_to_users.reset_mock()
# user_1 will no longer receive notifications
submit_form()
assert mocked_broadcast_to_users.call_count == 1
args = mocked_broadcast_to_users.call_args_list[0][0]
assert args[0][0] == [user_2.id]

View file

@ -92,6 +92,7 @@
<FormViewMetaControls
v-else
:view="view"
:database="database"
:read-only="readOnly"
@updated-form="updateForm($event)"
></FormViewMetaControls>

View file

@ -93,6 +93,16 @@
}
}
.notification-panel__notification-content-desc {
line-height: 20px;
margin-bottom: 12px;
}
.notification-panel__notification-content-summary {
padding-left: 22px;
margin-bottom: 8px;
}
.notification-panel__notification-status {
flex: 0 0 16px;
text-align: right;

View file

@ -114,6 +114,12 @@
color: $color-neutral-900;
}
.form-view__control-notification-on-submit {
display: flex;
gap: 10px;
align-items: center;
}
.form-view__sidebar-prefill-or-hide-link {
padding: 0 30px 30px 30px;
}

View file

@ -390,6 +390,7 @@
"removeAll": "Remove all",
"addField": "Add field"
},
"notifyUserOnSubmit": "Receive notification on submit",
"fieldsDescription": "All the fields are in the form.",
"prefillOrHideInfoLink": "Prefill or hide dynamically",
"modal": {

View file

@ -0,0 +1,85 @@
<template>
<nuxt-link
class="notification-panel__notification-link"
:to="url"
@click.native="markAsReadAndHandleClick"
>
<div class="notification-panel__notification-content-title">
<i18n path="formSubmittedNotification.title" tag="span">
<template #formName>
<strong>{{ notification.data.form_name }}</strong>
</template>
<template #tableName>
<strong>{{ notification.data.table_name }}</strong>
</template>
</i18n>
</div>
<div class="notification-panel__notification-content-desc">
<ul class="notification-panel__notification-content-summary">
<li v-for="(elem, index) in submittedValuesSummary" :key="index">
{{ elem.field }}: {{ elem.value }}
</li>
</ul>
<div v-if="hiddenFieldsCount > 0">
{{
$tc('formSubmittedNotification.moreValues', hiddenFieldsCount, {
count: hiddenFieldsCount,
})
}}
</div>
</div>
</nuxt-link>
</template>
<script>
import notificationContent from '@baserow/modules/core/mixins/notificationContent'
export default {
name: 'FormSubmittedNotification',
mixins: [notificationContent],
props: {
notification: {
type: Object,
required: true,
},
},
data() {
return {
limitValues: 3, // only the first 3 elements to keep it short
}
},
computed: {
params() {
return {
databaseId: this.notification.data.database_id,
tableId: this.notification.data.table_id,
rowId: this.notification.data.row_id,
}
},
url() {
return {
name: 'database-table-row',
params: this.params,
}
},
submittedValuesSummary() {
return this.notification.data.values
.slice(0, this.limitValues)
.map((elem) => {
return { field: elem[0], value: elem[1] }
})
},
hiddenFieldsCount() {
return Math.max(
0,
this.notification.data.values.length - this.limitValues
)
},
},
methods: {
handleClick() {
this.$emit('close-panel')
},
},
}
</script>

View file

@ -1,5 +1,25 @@
<template>
<div class="form-view__meta-controls">
<div
v-if="
!readOnly &&
$hasPermission(
'database.table.view.update',
view,
database.workspace.id
)
"
class="control form-view__control-notification-on-submit"
>
<SwitchInput
class=""
:value="view.receive_notification_on_submit"
@input="
$emit('updated-form', { receive_notification_on_submit: $event })
"
></SwitchInput>
{{ $t('formSidebar.notifyUserOnSubmit') }}
</div>
<div class="control">
<label class="control__label">When the form is submitted</label>
<div class="control__elements">
@ -103,6 +123,10 @@ export default {
type: Object,
required: true,
},
database: {
type: Object,
required: true,
},
readOnly: {
type: Boolean,
required: true,

View file

@ -132,6 +132,7 @@
<div class="form-view__body">
<FormViewMetaControls
:view="view"
:database="database"
:read-only="readOnly"
@updated-form="updateForm($event)"
></FormViewMetaControls>

View file

@ -803,6 +803,10 @@
"collaboratorAddedToRowNotification": {
"title": "{sender} assigned you to {fieldName} in row {rowId} in {tableName}"
},
"formSubmittedNotification": {
"title": "{formName} has been submitted in table {tableName}:",
"moreValues": "0 | and 1 more field. | and {count} more fields."
},
"rowHistorySidebar": {
"name": "History",
"empty": "No changes yet. You'll be able to track any changes to this row here.",

View file

@ -1,6 +1,7 @@
import { NotificationType } from '@baserow/modules/core/notificationTypes'
import NotificationSenderInitialsIcon from '@baserow/modules/core/components/notifications/NotificationSenderInitialsIcon'
import CollaboratorAddedToRowNotification from '@baserow/modules/database/components/notifications/CollaboratorAddedToRowNotification'
import FormSubmittedNotification from '@baserow/modules/database/components/notifications/FormSubmittedNotification'
export class CollaboratorAddedToRowNotificationType extends NotificationType {
static getType() {
@ -15,3 +16,17 @@ export class CollaboratorAddedToRowNotificationType extends NotificationType {
return CollaboratorAddedToRowNotification
}
}
export class FormSubmittedNotificationType extends NotificationType {
static getType() {
return 'form_submitted'
}
getIconComponent() {
return null
}
getContentComponent() {
return FormSubmittedNotification
}
}

View file

@ -242,7 +242,10 @@ import {
import { FormViewFormModeType } from '@baserow/modules/database/formViewModeTypes'
import { CollaborativeViewOwnershipType } from '@baserow/modules/database/viewOwnershipTypes'
import { DatabasePlugin } from '@baserow/modules/database/plugins'
import { CollaboratorAddedToRowNotificationType } from '@baserow/modules/database/notificationTypes'
import {
CollaboratorAddedToRowNotificationType,
FormSubmittedNotificationType,
} from '@baserow/modules/database/notificationTypes'
import { HistoryRowModalSidebarType } from '@baserow/modules/database/rowModalSidebarTypes'
import en from '@baserow/modules/database/locales/en.json'
@ -685,6 +688,10 @@ export default (context) => {
'notification',
new CollaboratorAddedToRowNotificationType(context)
)
app.$registry.register(
'notification',
new FormSubmittedNotificationType(context)
)
app.$registry.register(
'rowModalSidebar',