1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-13 16:49:07 +00:00

Resolve "The notification mode settings is not shown correctly if the row is not in the view"

This commit is contained in:
Davide Silvestri 2025-02-10 16:49:10 +00:00
parent f381d42848
commit bdf7d3e8c7
27 changed files with 361 additions and 138 deletions
backend
src/baserow/contrib/database
tests/baserow/contrib/database/api
changelog/entries/unreleased/bug
premium
backend
src/baserow_premium
tests/baserow_premium_tests/row_comments
web-frontend/modules/baserow_premium/store
web-frontend/modules/database

View file

@ -13,7 +13,10 @@ from baserow.api.utils import get_serializer_class
from baserow.contrib.database.api.rows.fields import UserFieldNamesField from baserow.contrib.database.api.rows.fields import UserFieldNamesField
from baserow.contrib.database.fields.registries import field_type_registry from baserow.contrib.database.fields.registries import field_type_registry
from baserow.contrib.database.rows.models import RowHistory from baserow.contrib.database.rows.models import RowHistory
from baserow.contrib.database.rows.registries import row_metadata_registry from baserow.contrib.database.rows.registries import (
RowMetadataType,
row_metadata_registry,
)
class RowSerializer(serializers.ModelSerializer): class RowSerializer(serializers.ModelSerializer):
@ -173,18 +176,18 @@ class BatchDeleteRowsSerializer(serializers.Serializer):
) )
def get_example_row_serializer_class(example_type="get", user_field_names=False): def get_example_row_serializer_class(
example_type: str = "get",
user_field_names: bool = False,
) -> serializers.Serializer:
""" """
Generates a serializer containing a field for each field type. It is only used for Generates a serializer containing a field for each field type. It is only used for
example purposes in the openapi documentation. example purposes in the openapi documentation.
:param example_type: Sets various parameters. Can be get, post, patch. :param example_type: Sets various parameters. Can be get, post, patch.
:type example_type: str
:param user_field_names: Whether this example serializer help text should indicate :param user_field_names: Whether this example serializer help text should indicate
the fields names can be switched using the `user_field_names` GET parameter. the fields names can be switched using the `user_field_names` GET parameter.
:type user_field_names: bool
:return: Generated serializer containing a field for each field type. :return: Generated serializer containing a field for each field type.
:rtype: Serializer
""" """
config = { config = {
@ -192,24 +195,28 @@ def get_example_row_serializer_class(example_type="get", user_field_names=False)
"class_name": "ExampleRowResponseSerializer", "class_name": "ExampleRowResponseSerializer",
"add_id": True, "add_id": True,
"add_order": True, "add_order": True,
"add_metadata": True,
"read_only_fields": True, "read_only_fields": True,
}, },
"post": { "post": {
"class_name": "ExampleRowRequestSerializer", "class_name": "ExampleRowRequestSerializer",
"add_id": False, "add_id": False,
"add_order": False, "add_order": False,
"add_metadata": False,
"read_only_fields": False, "read_only_fields": False,
}, },
"patch": { "patch": {
"class_name": "ExampleUpdateRowRequestSerializer", "class_name": "ExampleUpdateRowRequestSerializer",
"add_id": False, "add_id": False,
"add_order": False, "add_order": False,
"add_metadata": False,
"read_only_fields": False, "read_only_fields": False,
}, },
"patch_batch": { "patch_batch": {
"class_name": "ExampleBatchUpdateRowRequestSerializer", "class_name": "ExampleBatchUpdateRowRequestSerializer",
"add_id": True, "add_id": True,
"add_order": False, "add_order": False,
"add_metadata": False,
"read_only_fields": False, "read_only_fields": False,
}, },
} }
@ -217,6 +224,7 @@ def get_example_row_serializer_class(example_type="get", user_field_names=False)
class_name = config[example_type]["class_name"] class_name = config[example_type]["class_name"]
add_id = config[example_type]["add_id"] add_id = config[example_type]["add_id"]
add_order = config[example_type]["add_order"] add_order = config[example_type]["add_order"]
add_metadata = config[example_type]["add_metadata"]
add_readonly_fields = config[example_type]["read_only_fields"] add_readonly_fields = config[example_type]["read_only_fields"]
is_response_example = add_readonly_fields is_response_example = add_readonly_fields
@ -245,6 +253,16 @@ def get_example_row_serializer_class(example_type="get", user_field_names=False)
"last.", "last.",
) )
if add_metadata:
metadata_serializer = get_example_row_metadata_serializer()
fields["metadata"] = metadata_serializer(
required=False,
help_text=(
"Additional metadata for the row, if `include=metadata' "
"is provided as query parameter."
),
)
field_types = field_type_registry.registry.values() field_types = field_type_registry.registry.values()
if len(field_types) == 0: if len(field_types) == 0:
@ -285,30 +303,37 @@ def get_example_row_serializer_class(example_type="get", user_field_names=False)
return class_object return class_object
def get_example_row_metadata_field_serializer(): def get_example_row_metadata_serializer() -> serializers.Serializer:
""" """
Generates a serializer containing a field for each row metadata type which Generates a serializer containing a field for each row metadata type which
represents the metadata for a single row. represents the metadata for a single row.
It is only used for example purposes in the openapi documentation. :return: A serializer containing a field for each row metadata type.
:return: Generated serializer for a single rows metadata
:rtype: Serializer
""" """
metadata_types = row_metadata_registry.get_all() metadata_types: List[RowMetadataType] = row_metadata_registry.get_all()
if len(metadata_types) == 0:
return None
fields = {} fields = {}
for metadata_type in metadata_types: for metadata_type in metadata_types:
fields[metadata_type.type] = metadata_type.get_example_serializer_field() fields[metadata_type.type] = metadata_type.get_example_serializer_field()
per_row_serializer = type( return type("RowMetadataSerializer", (serializers.Serializer,), fields)
"RowMetadataSerializer", (serializers.Serializer,), fields
)()
def get_example_multiple_rows_metadata_serializer() -> serializers.Serializer:
"""
Generates a serializer containing a field for each row metadata type which
represents the metadata for a single row. The single row serializer is then
nested in a dictionary where the key is the row id and the value is the single row
metadata serializer.
It is only used for example purposes in the openapi documentation.
:return: A serializer containing a dictionary of row id to row metadata.
"""
per_row_serializer = get_example_row_metadata_serializer()
return serializers.DictField( return serializers.DictField(
child=per_row_serializer, child=per_row_serializer(),
required=False, required=False,
help_text="An object keyed by row id with a value being an object containing " help_text="An object keyed by row id with a value being an object containing "
"additional metadata about that row. A row might not have metadata and will " "additional metadata about that row. A row might not have metadata and will "

View file

@ -14,6 +14,7 @@ from rest_framework.status import HTTP_204_NO_CONTENT
from rest_framework.views import APIView from rest_framework.views import APIView
from baserow.api.decorators import ( from baserow.api.decorators import (
allowed_includes,
map_exceptions, map_exceptions,
require_request_data_type, require_request_data_type,
validate_body, validate_body,
@ -51,7 +52,10 @@ from baserow.contrib.database.api.rows.exceptions import InvalidJoinParameterExc
from baserow.contrib.database.api.rows.serializers import GetRowAdjacentSerializer from baserow.contrib.database.api.rows.serializers import GetRowAdjacentSerializer
from baserow.contrib.database.api.tables.errors import ERROR_TABLE_DOES_NOT_EXIST from baserow.contrib.database.api.tables.errors import ERROR_TABLE_DOES_NOT_EXIST
from baserow.contrib.database.api.tokens.authentications import TokenAuthentication from baserow.contrib.database.api.tokens.authentications import TokenAuthentication
from baserow.contrib.database.api.tokens.errors import ERROR_NO_PERMISSION_TO_TABLE from baserow.contrib.database.api.tokens.errors import (
ERROR_CANNOT_INCLUDE_ROW_METADATA,
ERROR_NO_PERMISSION_TO_TABLE,
)
from baserow.contrib.database.api.utils import ( from baserow.contrib.database.api.utils import (
extract_link_row_joins_from_request, extract_link_row_joins_from_request,
extract_send_webhook_events_from_params, extract_send_webhook_events_from_params,
@ -63,6 +67,7 @@ from baserow.contrib.database.api.views.errors import (
ERROR_VIEW_FILTER_TYPE_DOES_NOT_EXIST, ERROR_VIEW_FILTER_TYPE_DOES_NOT_EXIST,
ERROR_VIEW_FILTER_TYPE_UNSUPPORTED_FIELD, ERROR_VIEW_FILTER_TYPE_UNSUPPORTED_FIELD,
) )
from baserow.contrib.database.api.views.utils import serialize_single_row_metadata
from baserow.contrib.database.fields.exceptions import ( from baserow.contrib.database.fields.exceptions import (
FieldDoesNotExist, FieldDoesNotExist,
FilterFieldNotFound, FilterFieldNotFound,
@ -100,7 +105,10 @@ from baserow.contrib.database.table.operations import (
ListRowNamesDatabaseTableOperationType, ListRowNamesDatabaseTableOperationType,
ListRowsDatabaseTableOperationType, ListRowsDatabaseTableOperationType,
) )
from baserow.contrib.database.tokens.exceptions import NoPermissionToTable from baserow.contrib.database.tokens.exceptions import (
NoPermissionToTable,
TokenCannotIncludeRowMetadata,
)
from baserow.contrib.database.tokens.handler import TokenHandler from baserow.contrib.database.tokens.handler import TokenHandler
from baserow.contrib.database.views.exceptions import ( from baserow.contrib.database.views.exceptions import (
ViewDoesNotExist, ViewDoesNotExist,
@ -702,6 +710,16 @@ class RowView(APIView):
"field names (e.g., field_123)." "field names (e.g., field_123)."
), ),
), ),
OpenApiParameter(
name="include",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description=(
"Optionally include row's `metadata` in the response. "
"The `metadata` object includes extra row specific data like the "
"'row_comments_notification_mode' settings, if available."
),
),
], ],
tags=["Database table rows"], tags=["Database table rows"],
operation_id="get_database_table_row", operation_id="get_database_table_row",
@ -735,9 +753,11 @@ class RowView(APIView):
TableDoesNotExist: ERROR_TABLE_DOES_NOT_EXIST, TableDoesNotExist: ERROR_TABLE_DOES_NOT_EXIST,
RowDoesNotExist: ERROR_ROW_DOES_NOT_EXIST, RowDoesNotExist: ERROR_ROW_DOES_NOT_EXIST,
NoPermissionToTable: ERROR_NO_PERMISSION_TO_TABLE, NoPermissionToTable: ERROR_NO_PERMISSION_TO_TABLE,
TokenCannotIncludeRowMetadata: ERROR_CANNOT_INCLUDE_ROW_METADATA,
} }
) )
def get(self, request, table_id, row_id): @allowed_includes("metadata")
def get(self, request, table_id, row_id, metadata):
""" """
Responds with a serializer version of the row related to the provided row_id Responds with a serializer version of the row related to the provided row_id
and table_id. and table_id.
@ -745,7 +765,13 @@ class RowView(APIView):
table = TableHandler().get_table(table_id) table = TableHandler().get_table(table_id)
TokenHandler().check_table_permissions(request, "read", table, False) token_handler = TokenHandler()
db_token = token_handler.get_token_from_request(request)
if db_token is not None:
if metadata:
raise TokenCannotIncludeRowMetadata()
token_handler.check_table_permissions(db_token, "read", table)
user_field_names = extract_user_field_names_from_params(request.GET) user_field_names = extract_user_field_names_from_params(request.GET)
model = table.get_model() model = table.get_model()
row = RowHandler().get_row(request.user, table, row_id, model) row = RowHandler().get_row(request.user, table, row_id, model)
@ -753,10 +779,15 @@ class RowView(APIView):
model, RowSerializer, is_response=True, user_field_names=user_field_names model, RowSerializer, is_response=True, user_field_names=user_field_names
) )
serializer = serializer_class(row) serializer = serializer_class(row)
response_data = serializer.data
if metadata:
row_metadata = serialize_single_row_metadata(request.user, row)
response_data["metadata"] = row_metadata
rows_loaded.send(sender=self, table=table) rows_loaded.send(sender=self, table=table)
return Response(serializer.data) return Response(response_data)
@extend_schema( @extend_schema(
parameters=[ parameters=[

View file

@ -1,4 +1,8 @@
from rest_framework.status import HTTP_401_UNAUTHORIZED, HTTP_404_NOT_FOUND from rest_framework.status import (
HTTP_400_BAD_REQUEST,
HTTP_401_UNAUTHORIZED,
HTTP_404_NOT_FOUND,
)
ERROR_TOKEN_DOES_NOT_EXIST = ( ERROR_TOKEN_DOES_NOT_EXIST = (
"ERROR_TOKEN_DOES_NOT_EXIST", "ERROR_TOKEN_DOES_NOT_EXIST",
@ -10,3 +14,9 @@ ERROR_NO_PERMISSION_TO_TABLE = (
HTTP_401_UNAUTHORIZED, HTTP_401_UNAUTHORIZED,
"The token does not have permissions to the table.", "The token does not have permissions to the table.",
) )
ERROR_CANNOT_INCLUDE_ROW_METADATA = (
"ERROR_CANNOT_INCLUDE_ROW_METADATA",
HTTP_400_BAD_REQUEST,
"The token cannot include row metadata.",
)

View file

@ -29,7 +29,7 @@ from baserow.contrib.database.api.fields.errors import (
) )
from baserow.contrib.database.api.rows.serializers import ( from baserow.contrib.database.api.rows.serializers import (
RowSerializer, RowSerializer,
get_example_row_metadata_field_serializer, get_example_multiple_rows_metadata_serializer,
get_example_row_serializer_class, get_example_row_serializer_class,
get_row_serializer_class, get_row_serializer_class,
) )
@ -150,7 +150,7 @@ class GalleryViewView(APIView):
serializer_class=GalleryViewFieldOptionsSerializer, serializer_class=GalleryViewFieldOptionsSerializer,
required=False, required=False,
), ),
"row_metadata": get_example_row_metadata_field_serializer(), "row_metadata": get_example_multiple_rows_metadata_serializer(),
}, },
serializer_name="PaginationSerializerWithGalleryViewFieldOptions", serializer_name="PaginationSerializerWithGalleryViewFieldOptions",
), ),

View file

@ -39,7 +39,7 @@ from baserow.contrib.database.api.fields.errors import (
) )
from baserow.contrib.database.api.rows.serializers import ( from baserow.contrib.database.api.rows.serializers import (
RowSerializer, RowSerializer,
get_example_row_metadata_field_serializer, get_example_multiple_rows_metadata_serializer,
get_example_row_serializer_class, get_example_row_serializer_class,
get_row_serializer_class, get_row_serializer_class,
) )
@ -172,7 +172,7 @@ class GridViewView(APIView):
"field_options": FieldOptionsField( "field_options": FieldOptionsField(
serializer_class=GridViewFieldOptionsSerializer, required=False serializer_class=GridViewFieldOptionsSerializer, required=False
), ),
"row_metadata": get_example_row_metadata_field_serializer(), "row_metadata": get_example_multiple_rows_metadata_serializer(),
}, },
serializer_name="PaginationSerializerWithGridViewFieldOptions", serializer_name="PaginationSerializerWithGridViewFieldOptions",
), ),

View file

@ -221,6 +221,22 @@ def serialize_rows_metadata(
) )
def serialize_single_row_metadata(
user: AbstractUser, row: GeneratedTableModel
) -> Dict[str, Any]:
"""
Serializes the metadata for the provided row.
:param user: The user to serialize the metadata for.
:param row: The row to serialize the metadata for.
:return: The serialized metadata for the provided rows.
"""
return row_metadata_registry.generate_and_merge_metadata_for_row(
user, row.baserow_table, row.id
)
def serialize_group_by_fields_metadata( def serialize_group_by_fields_metadata(
queryset: QuerySet[GeneratedTableModel], queryset: QuerySet[GeneratedTableModel],
group_by_fields: List[Field], group_by_fields: List[Field],

View file

@ -16,3 +16,9 @@ class NoPermissionToTable(Exception):
""" """
Raised when a token does not have permissions to perform an operation for a table. Raised when a token does not have permissions to perform an operation for a table.
""" """
class TokenCannotIncludeRowMetadata(Exception):
"""
Raised if requested to include a row's metadata via token.
"""

View file

@ -395,8 +395,41 @@ class TokenHandler:
# At least one must be True # At least one must be True
return any([v is True for v in token_permission.values()]) return any([v is True for v in token_permission.values()])
def get_token_from_request(self, request: Request) -> Token | None:
"""
Extracts the token from the request. If the token is not found then None is
returned.
:param request: The request from which the token must be extracted.
:return: The extracted token or None if it could not be found.
"""
return getattr(request, "user_token", None)
def raise_table_permission_error(self, table: Table, type_name: str | list[str]):
"""
Raises an exception indicating that the provided token does not have permission
to the provided table. Used to raise a consistent exception when the token does
not have permission to the table.
:param table: The table object to check the permissions for.
:param type_name: The CRUD operation, create, read, update or delete to check
the permissions for. Can be a list if you want to check at least one of the
listed operation.
:raises NoPermissionToTable: Raised when the token does not have permissions to
"""
raise NoPermissionToTable(
f"The provided token does not have {type_name} "
f"permissions to table {table.id}."
)
def check_table_permissions( def check_table_permissions(
self, request_or_token, type_name, table, force_check=False self,
request_or_token: Request | Token,
type_name: str | list[str],
table: Table,
force_check=False,
): ):
""" """
Instead of returning True or False, this method will raise an exception if the Instead of returning True or False, this method will raise an exception if the
@ -404,52 +437,35 @@ class TokenHandler:
:param request_or_token: If a request is provided then the token will be :param request_or_token: If a request is provided then the token will be
extracted from the request. Otherwise a token object is expected. extracted from the request. Otherwise a token object is expected.
:type request_or_token: Request or Token
:param type_name: The CRUD operation, create, read, update or delete to check :param type_name: The CRUD operation, create, read, update or delete to check
the permissions for. Can be a list if you want to check at least one of the the permissions for. Can be a list if you want to check at least one of the
listed operation. listed operation.
:type type_name: str | list
:param table: The table object to check the permissions for. :param table: The table object to check the permissions for.
:type table: Table
:param force_check: Indicates if a NoPermissionToTable exception must be raised :param force_check: Indicates if a NoPermissionToTable exception must be raised
when the token could not be extracted from the request. This can be when the token could not be extracted from the request. This can be
useful if a view accepts multiple types of authentication. useful if a view accepts multiple types of authentication.
:type force_check: bool
:raises ValueError: when neither a Token or HttpRequest is provided. :raises ValueError: when neither a Token or HttpRequest is provided.
:raises NoPermissionToTable: when the token does not have permissions to the :raises NoPermissionToTable: when the token does not have permissions to the
table. table.
""" """
token = None token = None
if not isinstance(request_or_token, Request) and not isinstance(
request_or_token, Token
):
raise ValueError(
"The provided instance should be a HttpRequest or Token " "object."
)
if isinstance(request_or_token, Request) and hasattr(
request_or_token, "user_token"
):
token = request_or_token.user_token
if isinstance(request_or_token, Token): if isinstance(request_or_token, Token):
token = request_or_token token = request_or_token
elif isinstance(request_or_token, Request):
if not token and not force_check: token = self.get_token_from_request(request_or_token)
return else:
raise ValueError(
if ( "The provided instance should be a HttpRequest or Token object."
not token
and force_check
or not TokenHandler().has_table_permission(token, type_name, table)
):
raise NoPermissionToTable(
f"The provided token does not have {type_name} "
f"permissions to table {table.id}."
) )
should_check_permissions = token is not None or force_check
has_table_permissions = token is not None and self.has_table_permission(
token, type_name, table
)
if should_check_permissions and not has_table_permissions:
self.raise_table_permission_error(table, type_name)
def delete_token(self, user, token): def delete_token(self, user, token):
""" """
Deletes an existing token Deletes an existing token

View file

@ -233,7 +233,7 @@ def test_get_example_row_serializer_class():
num_readonly_fields = len( num_readonly_fields = len(
[ftype for ftype in field_type_registry.registry.values() if ftype.read_only] [ftype for ftype in field_type_registry.registry.values() if ftype.read_only]
) )
num_extra_response_fields = 2 # id + order num_extra_response_fields = 3 # id + order + metadata
num_difference = num_readonly_fields + num_extra_response_fields num_difference = num_readonly_fields + num_extra_response_fields
assert num_request_fields == num_response_fields - num_difference assert num_request_fields == num_response_fields - num_difference

View file

@ -677,3 +677,31 @@ def test_check_token(api_client, data_fixture):
) )
assert response.status_code == HTTP_200_OK assert response.status_code == HTTP_200_OK
assert response.json() == {"token": "OK"} assert response.json() == {"token": "OK"}
@pytest.mark.django_db
def test_cannot_get_row_metadata_from_token(api_client, data_fixture):
user = data_fixture.create_user()
workspace = data_fixture.create_workspace(user=user)
token = data_fixture.create_token(user=user, workspace=workspace)
database = data_fixture.create_database_application(workspace=workspace)
table = data_fixture.create_database_table(user=user, database=database)
row = table.get_model().objects.create()
def get_row_from_api_with_metadata():
return api_client.get(
reverse(
"api:database:rows:item",
kwargs={"table_id": table.id, "row_id": row.id},
)
+ "?include=metadata",
HTTP_AUTHORIZATION=f"Token {token.key}",
)
response = get_row_from_api_with_metadata()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json() == {
"error": "ERROR_CANNOT_INCLUDE_ROW_METADATA",
"detail": "The token cannot include row metadata.",
}

View file

@ -0,0 +1,7 @@
{
"type": "bug",
"message": "Fixed an issue where the notification mode settings were not displayed correctly if the row was not in the view.",
"issue_number": 3411,
"bullet_points": [],
"created_at": "2025-02-05"
}

View file

@ -4,7 +4,7 @@ from baserow_premium.views.models import CalendarViewFieldOptions
from rest_framework import serializers from rest_framework import serializers
from baserow.contrib.database.api.rows.serializers import ( from baserow.contrib.database.api.rows.serializers import (
get_example_row_metadata_field_serializer, get_example_multiple_rows_metadata_serializer,
get_example_row_serializer_class, get_example_row_serializer_class,
) )
from baserow.contrib.database.search.handler import ALL_SEARCH_MODES from baserow.contrib.database.search.handler import ALL_SEARCH_MODES
@ -75,6 +75,6 @@ def get_calendar_view_example_response_serializer():
"field_options": serializers.ListSerializer( "field_options": serializers.ListSerializer(
child=CalendarViewFieldOptionsSerializer() child=CalendarViewFieldOptionsSerializer()
), ),
"row_metadata": get_example_row_metadata_field_serializer(), "row_metadata": get_example_multiple_rows_metadata_serializer(),
}, },
) )

View file

@ -2,7 +2,7 @@ from baserow_premium.views.models import KanbanViewFieldOptions
from rest_framework import serializers from rest_framework import serializers
from baserow.contrib.database.api.rows.serializers import ( from baserow.contrib.database.api.rows.serializers import (
get_example_row_metadata_field_serializer, get_example_multiple_rows_metadata_serializer,
get_example_row_serializer_class, get_example_row_serializer_class,
) )
@ -37,6 +37,6 @@ def get_kanban_view_example_response_serializer():
"field_options": serializers.ListSerializer( "field_options": serializers.ListSerializer(
child=KanbanViewFieldOptionsSerializer() child=KanbanViewFieldOptionsSerializer()
), ),
"row_metadata": get_example_row_metadata_field_serializer(), "row_metadata": get_example_multiple_rows_metadata_serializer(),
}, },
) )

View file

@ -44,6 +44,7 @@ from baserow.contrib.database.api.fields.errors import (
ERROR_ORDER_BY_FIELD_NOT_POSSIBLE, ERROR_ORDER_BY_FIELD_NOT_POSSIBLE,
) )
from baserow.contrib.database.api.rows.serializers import ( from baserow.contrib.database.api.rows.serializers import (
get_example_multiple_rows_metadata_serializer,
get_example_row_serializer_class, get_example_row_serializer_class,
) )
from baserow.contrib.database.api.utils import get_include_exclude_field_ids from baserow.contrib.database.api.utils import get_include_exclude_field_ids
@ -56,6 +57,7 @@ from baserow.contrib.database.api.views.serializers import FieldOptionsField
from baserow.contrib.database.api.views.utils import ( from baserow.contrib.database.api.views.utils import (
get_public_view_authorization_token, get_public_view_authorization_token,
paginate_and_serialize_queryset, paginate_and_serialize_queryset,
serialize_rows_metadata,
serialize_view_field_options, serialize_view_field_options,
) )
from baserow.contrib.database.fields.exceptions import ( from baserow.contrib.database.fields.exceptions import (
@ -105,12 +107,12 @@ class TimelineViewView(APIView):
location=OpenApiParameter.QUERY, location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR, type=OpenApiTypes.STR,
description=( description=(
"A comma separated list allowing the values of " "A comma separated list allowing the values of `field_options` and "
"`field_options` which will add the object/objects with the " "`row_metadata` which will add the object/objects with the same "
"same "
"name to the response if included. The `field_options` object " "name to the response if included. The `field_options` object "
"contains user defined view settings for each field. For " "contains user defined view settings for each field. For example "
"example the field's width is included in here." "the field's width is included in here. The `row_metadata` object"
" includes extra row specific data on a per row basis."
), ),
), ),
ONLY_COUNT_API_PARAM, ONLY_COUNT_API_PARAM,
@ -152,6 +154,7 @@ class TimelineViewView(APIView):
serializer_class=TimelineViewFieldOptionsSerializer, serializer_class=TimelineViewFieldOptionsSerializer,
required=False, required=False,
), ),
"row_metadata": get_example_multiple_rows_metadata_serializer(),
}, },
serializer_name="PaginationSerializerWithTimelineViewFieldOptions", serializer_name="PaginationSerializerWithTimelineViewFieldOptions",
), ),
@ -185,9 +188,9 @@ class TimelineViewView(APIView):
IncompatibleField: ERROR_INCOMPATIBLE_FIELD, IncompatibleField: ERROR_INCOMPATIBLE_FIELD,
} }
) )
@allowed_includes("field_options") @allowed_includes("field_options", "row_metadata")
@validate_query_parameters(SearchQueryParamSerializer, return_validated=True) @validate_query_parameters(SearchQueryParamSerializer, return_validated=True)
def get(self, request, view_id, field_options, query_params): def get(self, request, view_id, field_options, row_metadata, query_params):
""" """
Lists all the rows of a timeline view, paginated either by a page or Lists all the rows of a timeline view, paginated either by a page or
offset/limit. If the limit get parameter is provided the limit/offset pagination offset/limit. If the limit get parameter is provided the limit/offset pagination
@ -238,11 +241,18 @@ class TimelineViewView(APIView):
if "count" in request.GET: if "count" in request.GET:
return Response({"count": queryset.count()}) return Response({"count": queryset.count()})
response, _, _ = paginate_and_serialize_queryset(queryset, request, field_ids) response, page, _ = paginate_and_serialize_queryset(
queryset, request, field_ids
)
if field_options: if field_options:
response.data.update(**serialize_view_field_options(view, model)) response.data.update(**serialize_view_field_options(view, model))
if row_metadata:
response.data.update(
row_metadata=serialize_rows_metadata(request.user, view, page)
)
view_loaded.send( view_loaded.send(
sender=self, sender=self,
table=view.table, table=view.table,

View file

@ -63,6 +63,9 @@ ALL_ROW_COMMENT_NOTIFICATION_MODES = [
] ]
ROW_COMMENT_NOTIFICATION_DEFAULT_MODE = RowCommentsNotificationModes.MODE_ONLY_MENTIONS
class RowCommentsNotificationMode(CreatedAndUpdatedOnMixin, models.Model): class RowCommentsNotificationMode(CreatedAndUpdatedOnMixin, models.Model):
""" """
A many to many relationship between users and table rows to keep track of A many to many relationship between users and table rows to keep track of
@ -91,7 +94,7 @@ class RowCommentsNotificationMode(CreatedAndUpdatedOnMixin, models.Model):
(RowCommentsNotificationModes.MODE_ALL_COMMENTS, "All comments"), (RowCommentsNotificationModes.MODE_ALL_COMMENTS, "All comments"),
(RowCommentsNotificationModes.MODE_ONLY_MENTIONS, "Only mentions"), (RowCommentsNotificationModes.MODE_ONLY_MENTIONS, "Only mentions"),
), ),
default=RowCommentsNotificationModes.MODE_ONLY_MENTIONS, default=ROW_COMMENT_NOTIFICATION_DEFAULT_MODE,
help_text="The notification mode for this user and row.", help_text="The notification mode for this user and row.",
) )

View file

@ -4,9 +4,9 @@ from django.db.models import Count
from baserow_premium.row_comments.models import ( from baserow_premium.row_comments.models import (
ALL_ROW_COMMENT_NOTIFICATION_MODES, ALL_ROW_COMMENT_NOTIFICATION_MODES,
ROW_COMMENT_NOTIFICATION_DEFAULT_MODE,
RowComment, RowComment,
RowCommentsNotificationMode, RowCommentsNotificationMode,
RowCommentsNotificationModes,
) )
from rest_framework import serializers from rest_framework import serializers
from rest_framework.fields import Field from rest_framework.fields import Field
@ -38,7 +38,7 @@ class RowCommentCountMetadataType(RowMetadataType):
) )
class RowCommentsNotificationModeMetadataType(RowCommentCountMetadataType): class RowCommentsNotificationModeMetadataType(RowMetadataType):
type = "row_comments_notification_mode" type = "row_comments_notification_mode"
def generate_metadata_for_rows( def generate_metadata_for_rows(
@ -61,7 +61,7 @@ class RowCommentsNotificationModeMetadataType(RowCommentCountMetadataType):
table=table, table=table,
row_id__in=row_ids, row_id__in=row_ids,
) )
.exclude(mode=RowCommentsNotificationModes.MODE_ONLY_MENTIONS) .exclude(mode=ROW_COMMENT_NOTIFICATION_DEFAULT_MODE)
.values("row_id", "mode") .values("row_id", "mode")
) )
} }

View file

@ -648,3 +648,44 @@ def test_row_comment_notification_type_can_be_rendered_as_email(
) )
== "Test comment" == "Test comment"
) )
@override_settings(DEBUG=True)
@pytest.mark.django_db
def test_can_get_row_comment_notification_mode_from_row_metadata(
api_client, premium_data_fixture
):
user, token = premium_data_fixture.create_user_and_token(
has_active_premium_license=True
)
table = premium_data_fixture.create_database_table(user=user)
row = table.get_model().objects.create()
def get_row_from_api_with_metadata():
return api_client.get(
reverse(
"api:database:rows:item",
kwargs={"table_id": table.id, "row_id": row.id},
)
+ "?include=metadata",
HTTP_AUTHORIZATION=f"JWT {token}",
)
response = get_row_from_api_with_metadata()
assert response.status_code == HTTP_200_OK
assert response.json()["metadata"] == {}
response = api_client.put(
reverse(
"api:premium:row_comments:notification_mode",
kwargs={"row_id": row.id, "table_id": table.id},
),
{"mode": "all"},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_204_NO_CONTENT
response = get_row_from_api_with_metadata()
assert response.status_code == HTTP_200_OK
assert response.json()["metadata"]["row_comments_notification_mode"] == "all"

View file

@ -326,13 +326,22 @@ export const actions = {
/** /**
* Forcefully update the notification mode for a comments on a row. * Forcefully update the notification mode for a comments on a row.
*/ */
async forceUpdateNotificationMode({ commit }, { tableId, rowId, mode }) { async forceUpdateNotificationMode({ dispatch }, { tableId, rowId, mode }) {
const updateFunction = () => mode.toString()
const rowMetadataType = 'row_comments_notification_mode'
await updateRowMetadataInViews( await updateRowMetadataInViews(
this, this,
tableId, tableId,
rowId, rowId,
'row_comments_notification_mode', rowMetadataType,
() => mode.toString() updateFunction
)
// Let's also make sure the local copy of the row edit modal is updated in case the
// row is not in any view buffer.
dispatch(
'rowModal/updateRowMetadata',
{ rowId, rowMetadataType, updateFunction },
{ root: true }
) )
}, },
} }

View file

@ -21,12 +21,14 @@ import {
extractRowReadOnlyValues, extractRowReadOnlyValues,
prepareNewOldAndUpdateRequestValues, prepareNewOldAndUpdateRequestValues,
prepareRowForRequest, prepareRowForRequest,
updateRowMetadataType,
getRowMetadata,
} from '@baserow/modules/database/utils/row' } from '@baserow/modules/database/utils/row'
import { getDefaultSearchModeFromEnv } from '@baserow/modules/database/utils/search' import { getDefaultSearchModeFromEnv } from '@baserow/modules/database/utils/search'
export function populateRow(row, metadata = {}) { export function populateRow(row, metadata = {}) {
row._ = { row._ = {
metadata, metadata: getRowMetadata(row, metadata),
// Whether the row should be displayed based on the current activeSearchTerm term. // Whether the row should be displayed based on the current activeSearchTerm term.
matchSearch: true, matchSearch: true,
// Contains the specific field ids which match the activeSearchTerm term. // Contains the specific field ids which match the activeSearchTerm term.
@ -212,18 +214,7 @@ export const mutations = {
Object.assign(row, values) Object.assign(row, values)
}, },
UPDATE_ROW_METADATA(state, { row, rowMetadataType, updateFunction }) { UPDATE_ROW_METADATA(state, { row, rowMetadataType, updateFunction }) {
const currentValue = row._.metadata[rowMetadataType] updateRowMetadataType(row, rowMetadataType, updateFunction)
const newValue = updateFunction(currentValue)
if (
!Object.prototype.hasOwnProperty.call(row._.metadata, rowMetadataType)
) {
const metaDataCopy = clone(row._.metadata)
metaDataCopy[rowMetadataType] = newValue
Vue.set(row._, 'metadata', metaDataCopy)
} else {
Vue.set(row._.metadata, rowMetadataType, newValue)
}
}, },
SET_ADHOC_FILTERING(state, adhocFiltering) { SET_ADHOC_FILTERING(state, adhocFiltering) {
state.adhocFiltering = adhocFiltering state.adhocFiltering = adhocFiltering

View file

@ -17,11 +17,13 @@ import {
extractRowReadOnlyValues, extractRowReadOnlyValues,
prepareNewOldAndUpdateRequestValues, prepareNewOldAndUpdateRequestValues,
prepareRowForRequest, prepareRowForRequest,
updateRowMetadataType,
getRowMetadata,
} from '@baserow/modules/database/utils/row' } from '@baserow/modules/database/utils/row'
export function populateRow(row, metadata = {}) { export function populateRow(row, metadata = {}) {
row._ = { row._ = {
metadata, metadata: getRowMetadata(row, metadata),
dragging: false, dragging: false,
} }
return row return row
@ -180,18 +182,7 @@ export const mutations = {
Object.assign(row, values) Object.assign(row, values)
}, },
UPDATE_ROW_METADATA(state, { row, rowMetadataType, updateFunction }) { UPDATE_ROW_METADATA(state, { row, rowMetadataType, updateFunction }) {
const currentValue = row._.metadata[rowMetadataType] updateRowMetadataType(row, rowMetadataType, updateFunction)
const newValue = updateFunction(currentValue)
if (
!Object.prototype.hasOwnProperty.call(row._.metadata, rowMetadataType)
) {
const metaDataCopy = clone(row._.metadata)
metaDataCopy[rowMetadataType] = newValue
Vue.set(row._, 'metadata', metaDataCopy)
} else {
Vue.set(row._.metadata, rowMetadataType, newValue)
}
}, },
SET_ADHOC_FILTERING(state, adhocFiltering) { SET_ADHOC_FILTERING(state, adhocFiltering) {
state.adhocFiltering = adhocFiltering state.adhocFiltering = adhocFiltering

View file

@ -1,9 +1,10 @@
import bufferedRows from '@baserow/modules/database/store/view/bufferedRows' import bufferedRows from '@baserow/modules/database/store/view/bufferedRows'
import TimelineService from '@baserow_premium/services/views/timeline' import TimelineService from '@baserow_premium/services/views/timeline'
import { getRowMetadata } from '@baserow/modules/database/utils/row'
export function populateRow(row, metadata = {}) { export function populateRow(row, metadata = {}) {
row._ = { row._ = {
metadata, metadata: getRowMetadata(row, metadata),
} }
return row return row
} }
@ -32,7 +33,6 @@ export const actions = {
fields, fields,
initialRowArguments: { initialRowArguments: {
includeFieldOptions: true, includeFieldOptions: true,
includeRowMetadata: false,
}, },
adhocFiltering, adhocFiltering,
adhocSorting, adhocSorting,

View file

@ -7,8 +7,13 @@ const groupGetNameCalls = callGrouper(GRACE_DELAY)
export default (client) => { export default (client) => {
return { return {
get(tableId, rowId) { get(tableId, rowId, includeMetadata = true) {
return client.get(`/database/rows/table/${tableId}/${rowId}/`) const searchParams = {}
if (includeMetadata) {
searchParams.include = 'metadata'
}
const params = new URLSearchParams(searchParams).toString()
return client.get(`/database/rows/table/${tableId}/${rowId}/?${params}`)
}, },
fetchAll({ fetchAll({
tableId, tableId,

View file

@ -1,3 +1,5 @@
import { updateRowMetadataType } from '@baserow/modules/database/utils/row'
/** /**
* This store exists to always keep a copy of the row that's being edited via the * This store exists to always keep a copy of the row that's being edited via the
* row edit modal. It sometimes happen that row from the original source, where it was * row edit modal. It sometimes happen that row from the original source, where it was
@ -53,6 +55,13 @@ export const mutations = {
UPDATE_ROW(state, { componentId, row }) { UPDATE_ROW(state, { componentId, row }) {
Object.assign(state.rows[componentId].row, row) Object.assign(state.rows[componentId].row, row)
}, },
UPDATE_ROW_METADATA(state, { rowId, rowMetadataType, updateFunction }) {
Object.values(state.rows)
.filter((data) => data.row.id === rowId)
.forEach((data) =>
updateRowMetadataType(data.row, rowMetadataType, updateFunction)
)
},
} }
export const actions = { export const actions = {
@ -102,6 +111,14 @@ export const actions = {
} }
}) })
}, },
/**
* If a row is open in the modal but it's not present in the buffer, we need to
* manually update the metadata of the row. This is used for example to update the
* notification_mode setting of a row.
*/
updateRowMetadata({ commit }, { rowId, rowMetadataType, updateFunction }) {
commit('UPDATE_ROW_METADATA', { rowId, rowMetadataType, updateFunction })
},
} }
export const getters = { export const getters = {

View file

@ -16,6 +16,8 @@ import {
extractRowReadOnlyValues, extractRowReadOnlyValues,
prepareNewOldAndUpdateRequestValues, prepareNewOldAndUpdateRequestValues,
prepareRowForRequest, prepareRowForRequest,
updateRowMetadataType,
getRowMetadata,
} from '@baserow/modules/database/utils/row' } from '@baserow/modules/database/utils/row'
import { getDefaultSearchModeFromEnv } from '@baserow/modules/database/utils/search' import { getDefaultSearchModeFromEnv } from '@baserow/modules/database/utils/search'
import fieldOptionsStoreFactory from '@baserow/modules/database/store/view/fieldOptions' import fieldOptionsStoreFactory from '@baserow/modules/database/store/view/fieldOptions'
@ -59,13 +61,16 @@ export default ({ service, customPopulateRow, fieldOptions }) => {
const populateRow = (row, metadata = {}) => { const populateRow = (row, metadata = {}) => {
if (customPopulateRow) { if (customPopulateRow) {
customPopulateRow(row) customPopulateRow(row, metadata)
} }
// Add the metadata to the row so that it can be used in the front-end.
if (row._ == null) { if (row._ == null) {
row._ = { row._ = {
metadata, metadata: getRowMetadata(row, metadata),
} }
} }
// Matching rows for front-end only search is not yet properly // Matching rows for front-end only search is not yet properly
// supported and tested in this store mixin. Only server-side search // supported and tested in this store mixin. Only server-side search
// implementation is finished. // implementation is finished.
@ -270,18 +275,7 @@ export default ({ service, customPopulateRow, fieldOptions }) => {
row._.matchSearch = matchSearch row._.matchSearch = matchSearch
}, },
UPDATE_ROW_METADATA(state, { row, rowMetadataType, updateFunction }) { UPDATE_ROW_METADATA(state, { row, rowMetadataType, updateFunction }) {
const currentValue = row._.metadata[rowMetadataType] updateRowMetadataType(row, rowMetadataType, updateFunction)
const newValue = updateFunction(currentValue)
if (
!Object.prototype.hasOwnProperty.call(row._.metadata, rowMetadataType)
) {
const metaDataCopy = clone(row._.metadata)
metaDataCopy[rowMetadataType] = newValue
Vue.set(row._, 'metadata', metaDataCopy)
} else {
Vue.set(row._.metadata, rowMetadataType, newValue)
}
}, },
SET_ADHOC_FILTERING(state, adhocFiltering) { SET_ADHOC_FILTERING(state, adhocFiltering) {
state.adhocFiltering = adhocFiltering state.adhocFiltering = adhocFiltering
@ -507,7 +501,8 @@ export default ({ service, customPopulateRow, fieldOptions }) => {
}) })
data.results.forEach((row, index) => { data.results.forEach((row, index) => {
rows[rangeToFetch.offset + index] = populateRow(row) const metadata = extractRowMetadata(data, row.id)
rows[rangeToFetch.offset + index] = populateRow(row, metadata)
}) })
if (includeFieldOptions) { if (includeFieldOptions) {

View file

@ -1,9 +1,10 @@
import bufferedRows from '@baserow/modules/database/store/view/bufferedRows' import bufferedRows from '@baserow/modules/database/store/view/bufferedRows'
import GalleryService from '@baserow/modules/database/services/view/gallery' import GalleryService from '@baserow/modules/database/services/view/gallery'
import { getRowMetadata } from '@baserow/modules/database/utils/row'
export function populateRow(row, metadata = {}) { export function populateRow(row, metadata = {}) {
row._ = { row._ = {
metadata, metadata: getRowMetadata(row, metadata),
dragging: false, dragging: false,
} }
return row return row

View file

@ -23,6 +23,8 @@ import {
prepareRowForRequest, prepareRowForRequest,
prepareNewOldAndUpdateRequestValues, prepareNewOldAndUpdateRequestValues,
extractRowReadOnlyValues, extractRowReadOnlyValues,
updateRowMetadataType,
getRowMetadata,
} from '@baserow/modules/database/utils/row' } from '@baserow/modules/database/utils/row'
import { getDefaultSearchModeFromEnv } from '@baserow/modules/database/utils/search' import { getDefaultSearchModeFromEnv } from '@baserow/modules/database/utils/search'
import { fieldValuesAreEqualInObjects } from '@baserow/modules/database/utils/groupBy' import { fieldValuesAreEqualInObjects } from '@baserow/modules/database/utils/groupBy'
@ -32,7 +34,7 @@ const ORDER_STEP_BEFORE = '0.00000000000000000001'
export function populateRow(row, metadata = {}) { export function populateRow(row, metadata = {}) {
row._ = { row._ = {
metadata, metadata: getRowMetadata(row, metadata),
persistentId: uuid(), persistentId: uuid(),
loading: false, loading: false,
hover: false, hover: false,
@ -50,6 +52,7 @@ export function populateRow(row, metadata = {}) {
selected: false, selected: false,
selectedFieldId: -1, selectedFieldId: -1,
} }
return row return row
} }
@ -439,18 +442,7 @@ export const mutations = {
row[`field_${field.id}`] = value row[`field_${field.id}`] = value
}, },
UPDATE_ROW_METADATA(state, { row, rowMetadataType, updateFunction }) { UPDATE_ROW_METADATA(state, { row, rowMetadataType, updateFunction }) {
const currentValue = row._.metadata[rowMetadataType] updateRowMetadataType(row, rowMetadataType, updateFunction)
const newValue = updateFunction(currentValue)
if (
!Object.prototype.hasOwnProperty.call(row._.metadata, rowMetadataType)
) {
const metaDataCopy = clone(row._.metadata)
metaDataCopy[rowMetadataType] = newValue
Vue.set(row._, 'metadata', metaDataCopy)
} else {
Vue.set(row._.metadata, rowMetadataType, newValue)
}
}, },
FINALIZE_ROWS_IN_BUFFER(state, { oldRows, newRows, fields }) { FINALIZE_ROWS_IN_BUFFER(state, { oldRows, newRows, fields }) {
const stateRowsCopy = { ...state.rows } const stateRowsCopy = { ...state.rows }

View file

@ -1,3 +1,6 @@
import Vue from 'vue'
import { clone } from '@baserow/modules/core/utils/object'
/** /**
* Serializes a row to make sure that the values are according to what the API expects. * Serializes a row to make sure that the values are according to what the API expects.
* *
@ -103,3 +106,29 @@ export function extractRowReadOnlyValues(row, allFields, registry) {
}) })
return readOnlyValues return readOnlyValues
} }
/**
* Call the given updateFunction with the current value of the row metadata type and
* set the new value. If the row metadata type does not exist yet, it will be
* created.
*/
export function updateRowMetadataType(row, rowMetadataType, updateFunction) {
const currentValue = row._.metadata[rowMetadataType]
const newValue = updateFunction(currentValue)
if (!Object.prototype.hasOwnProperty.call(row._.metadata, rowMetadataType)) {
const metaDataCopy = clone(row._.metadata)
metaDataCopy[rowMetadataType] = newValue
Vue.set(row._, 'metadata', metaDataCopy)
} else {
Vue.set(row._.metadata, rowMetadataType, newValue)
}
}
/**
* Return the metadata of a row. If the metadata does not exist yet, it will be created
* as an empty object.
*/
export function getRowMetadata(row, metadata = {}) {
return { ...metadata, ...(row.metadata || {}) }
}