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:
parent
37764dfd98
commit
d1702e40b1
43 changed files with 1299 additions and 236 deletions
backend
src/baserow
api
contrib
builder/locale/en/LC_MESSAGES
database
core
locale/en/LC_MESSAGES
test_utils
tests/baserow
contrib/database
api/views/form
view
core
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
|
@ -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)
|
||||
|
||||
|
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
@ -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 ""
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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 ""
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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]
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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):
|
||||
|
|
125
backend/src/baserow/contrib/database/views/notification_types.py
Normal file
125
backend/src/baserow/contrib/database/views/notification_types.py
Normal 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
|
||||
)
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
@ -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 ""
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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 ""
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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]
|
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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]
|
|
@ -92,6 +92,7 @@
|
|||
<FormViewMetaControls
|
||||
v-else
|
||||
:view="view"
|
||||
:database="database"
|
||||
:read-only="readOnly"
|
||||
@updated-form="updateForm($event)"
|
||||
></FormViewMetaControls>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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>
|
|
@ -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,
|
||||
|
|
|
@ -132,6 +132,7 @@
|
|||
<div class="form-view__body">
|
||||
<FormViewMetaControls
|
||||
:view="view"
|
||||
:database="database"
|
||||
:read-only="readOnly"
|
||||
@updated-form="updateForm($event)"
|
||||
></FormViewMetaControls>
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
Loading…
Add table
Reference in a new issue