mirror of
https://gitlab.com/bramw/baserow.git
synced 2025-04-03 04:35:31 +00:00
Resolve "The notification mode settings is not shown correctly if the row is not in the view"
This commit is contained in:
parent
f381d42848
commit
bdf7d3e8c7
27 changed files with 361 additions and 138 deletions
backend
src/baserow/contrib/database
api
tokens
tests/baserow/contrib/database/api
changelog/entries/unreleased/bug
premium
backend
src/baserow_premium
api/views
row_comments
tests/baserow_premium_tests/row_comments
web-frontend/modules/baserow_premium/store
web-frontend/modules/database
|
@ -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.fields.registries import field_type_registry
|
||||
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):
|
||||
|
@ -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
|
||||
example purposes in the openapi documentation.
|
||||
|
||||
: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
|
||||
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.
|
||||
:rtype: Serializer
|
||||
"""
|
||||
|
||||
config = {
|
||||
|
@ -192,24 +195,28 @@ def get_example_row_serializer_class(example_type="get", user_field_names=False)
|
|||
"class_name": "ExampleRowResponseSerializer",
|
||||
"add_id": True,
|
||||
"add_order": True,
|
||||
"add_metadata": True,
|
||||
"read_only_fields": True,
|
||||
},
|
||||
"post": {
|
||||
"class_name": "ExampleRowRequestSerializer",
|
||||
"add_id": False,
|
||||
"add_order": False,
|
||||
"add_metadata": False,
|
||||
"read_only_fields": False,
|
||||
},
|
||||
"patch": {
|
||||
"class_name": "ExampleUpdateRowRequestSerializer",
|
||||
"add_id": False,
|
||||
"add_order": False,
|
||||
"add_metadata": False,
|
||||
"read_only_fields": False,
|
||||
},
|
||||
"patch_batch": {
|
||||
"class_name": "ExampleBatchUpdateRowRequestSerializer",
|
||||
"add_id": True,
|
||||
"add_order": False,
|
||||
"add_metadata": 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"]
|
||||
add_id = config[example_type]["add_id"]
|
||||
add_order = config[example_type]["add_order"]
|
||||
add_metadata = config[example_type]["add_metadata"]
|
||||
add_readonly_fields = config[example_type]["read_only_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.",
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
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
|
||||
|
||||
|
||||
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.
|
||||
It is only used for example purposes in the openapi documentation.
|
||||
|
||||
:return: Generated serializer for a single rows metadata
|
||||
:rtype: Serializer
|
||||
:return: A serializer containing a field for each row metadata type.
|
||||
"""
|
||||
|
||||
metadata_types = row_metadata_registry.get_all()
|
||||
|
||||
if len(metadata_types) == 0:
|
||||
return None
|
||||
metadata_types: List[RowMetadataType] = row_metadata_registry.get_all()
|
||||
|
||||
fields = {}
|
||||
for metadata_type in metadata_types:
|
||||
fields[metadata_type.type] = metadata_type.get_example_serializer_field()
|
||||
|
||||
per_row_serializer = type(
|
||||
"RowMetadataSerializer", (serializers.Serializer,), fields
|
||||
)()
|
||||
return type("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(
|
||||
child=per_row_serializer,
|
||||
child=per_row_serializer(),
|
||||
required=False,
|
||||
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 "
|
||||
|
|
|
@ -14,6 +14,7 @@ from rest_framework.status import HTTP_204_NO_CONTENT
|
|||
from rest_framework.views import APIView
|
||||
|
||||
from baserow.api.decorators import (
|
||||
allowed_includes,
|
||||
map_exceptions,
|
||||
require_request_data_type,
|
||||
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.tables.errors import ERROR_TABLE_DOES_NOT_EXIST
|
||||
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 (
|
||||
extract_link_row_joins_from_request,
|
||||
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_UNSUPPORTED_FIELD,
|
||||
)
|
||||
from baserow.contrib.database.api.views.utils import serialize_single_row_metadata
|
||||
from baserow.contrib.database.fields.exceptions import (
|
||||
FieldDoesNotExist,
|
||||
FilterFieldNotFound,
|
||||
|
@ -100,7 +105,10 @@ from baserow.contrib.database.table.operations import (
|
|||
ListRowNamesDatabaseTableOperationType,
|
||||
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.views.exceptions import (
|
||||
ViewDoesNotExist,
|
||||
|
@ -702,6 +710,16 @@ class RowView(APIView):
|
|||
"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"],
|
||||
operation_id="get_database_table_row",
|
||||
|
@ -735,9 +753,11 @@ class RowView(APIView):
|
|||
TableDoesNotExist: ERROR_TABLE_DOES_NOT_EXIST,
|
||||
RowDoesNotExist: ERROR_ROW_DOES_NOT_EXIST,
|
||||
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
|
||||
and table_id.
|
||||
|
@ -745,7 +765,13 @@ class RowView(APIView):
|
|||
|
||||
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)
|
||||
model = table.get_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
|
||||
)
|
||||
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)
|
||||
|
||||
return Response(serializer.data)
|
||||
return Response(response_data)
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
|
|
|
@ -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",
|
||||
|
@ -10,3 +14,9 @@ ERROR_NO_PERMISSION_TO_TABLE = (
|
|||
HTTP_401_UNAUTHORIZED,
|
||||
"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.",
|
||||
)
|
||||
|
|
|
@ -29,7 +29,7 @@ from baserow.contrib.database.api.fields.errors import (
|
|||
)
|
||||
from baserow.contrib.database.api.rows.serializers import (
|
||||
RowSerializer,
|
||||
get_example_row_metadata_field_serializer,
|
||||
get_example_multiple_rows_metadata_serializer,
|
||||
get_example_row_serializer_class,
|
||||
get_row_serializer_class,
|
||||
)
|
||||
|
@ -150,7 +150,7 @@ class GalleryViewView(APIView):
|
|||
serializer_class=GalleryViewFieldOptionsSerializer,
|
||||
required=False,
|
||||
),
|
||||
"row_metadata": get_example_row_metadata_field_serializer(),
|
||||
"row_metadata": get_example_multiple_rows_metadata_serializer(),
|
||||
},
|
||||
serializer_name="PaginationSerializerWithGalleryViewFieldOptions",
|
||||
),
|
||||
|
|
|
@ -39,7 +39,7 @@ from baserow.contrib.database.api.fields.errors import (
|
|||
)
|
||||
from baserow.contrib.database.api.rows.serializers import (
|
||||
RowSerializer,
|
||||
get_example_row_metadata_field_serializer,
|
||||
get_example_multiple_rows_metadata_serializer,
|
||||
get_example_row_serializer_class,
|
||||
get_row_serializer_class,
|
||||
)
|
||||
|
@ -172,7 +172,7 @@ class GridViewView(APIView):
|
|||
"field_options": FieldOptionsField(
|
||||
serializer_class=GridViewFieldOptionsSerializer, required=False
|
||||
),
|
||||
"row_metadata": get_example_row_metadata_field_serializer(),
|
||||
"row_metadata": get_example_multiple_rows_metadata_serializer(),
|
||||
},
|
||||
serializer_name="PaginationSerializerWithGridViewFieldOptions",
|
||||
),
|
||||
|
|
|
@ -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(
|
||||
queryset: QuerySet[GeneratedTableModel],
|
||||
group_by_fields: List[Field],
|
||||
|
|
|
@ -16,3 +16,9 @@ class NoPermissionToTable(Exception):
|
|||
"""
|
||||
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.
|
||||
"""
|
||||
|
|
|
@ -395,8 +395,41 @@ class TokenHandler:
|
|||
# At least one must be True
|
||||
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(
|
||||
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
|
||||
|
@ -404,52 +437,35 @@ class TokenHandler:
|
|||
|
||||
:param request_or_token: If a request is provided then the token will be
|
||||
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
|
||||
the permissions for. Can be a list if you want to check at least one of the
|
||||
listed operation.
|
||||
:type type_name: str | list
|
||||
:param table: The table object to check the permissions for.
|
||||
:type table: Table
|
||||
:param force_check: Indicates if a NoPermissionToTable exception must be raised
|
||||
when the token could not be extracted from the request. This can be
|
||||
useful if a view accepts multiple types of authentication.
|
||||
:type force_check: bool
|
||||
:raises ValueError: when neither a Token or HttpRequest is provided.
|
||||
:raises NoPermissionToTable: when the token does not have permissions to the
|
||||
table.
|
||||
"""
|
||||
|
||||
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):
|
||||
token = request_or_token
|
||||
|
||||
if not token and not force_check:
|
||||
return
|
||||
|
||||
if (
|
||||
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}."
|
||||
elif isinstance(request_or_token, Request):
|
||||
token = self.get_token_from_request(request_or_token)
|
||||
else:
|
||||
raise ValueError(
|
||||
"The provided instance should be a HttpRequest or Token object."
|
||||
)
|
||||
|
||||
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):
|
||||
"""
|
||||
Deletes an existing token
|
||||
|
|
|
@ -233,7 +233,7 @@ def test_get_example_row_serializer_class():
|
|||
num_readonly_fields = len(
|
||||
[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
|
||||
|
||||
assert num_request_fields == num_response_fields - num_difference
|
||||
|
|
|
@ -677,3 +677,31 @@ def test_check_token(api_client, data_fixture):
|
|||
)
|
||||
assert response.status_code == HTTP_200_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.",
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
|
@ -4,7 +4,7 @@ from baserow_premium.views.models import CalendarViewFieldOptions
|
|||
from rest_framework import serializers
|
||||
|
||||
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,
|
||||
)
|
||||
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(
|
||||
child=CalendarViewFieldOptionsSerializer()
|
||||
),
|
||||
"row_metadata": get_example_row_metadata_field_serializer(),
|
||||
"row_metadata": get_example_multiple_rows_metadata_serializer(),
|
||||
},
|
||||
)
|
||||
|
|
|
@ -2,7 +2,7 @@ from baserow_premium.views.models import KanbanViewFieldOptions
|
|||
from rest_framework import serializers
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
@ -37,6 +37,6 @@ def get_kanban_view_example_response_serializer():
|
|||
"field_options": serializers.ListSerializer(
|
||||
child=KanbanViewFieldOptionsSerializer()
|
||||
),
|
||||
"row_metadata": get_example_row_metadata_field_serializer(),
|
||||
"row_metadata": get_example_multiple_rows_metadata_serializer(),
|
||||
},
|
||||
)
|
||||
|
|
|
@ -44,6 +44,7 @@ from baserow.contrib.database.api.fields.errors import (
|
|||
ERROR_ORDER_BY_FIELD_NOT_POSSIBLE,
|
||||
)
|
||||
from baserow.contrib.database.api.rows.serializers import (
|
||||
get_example_multiple_rows_metadata_serializer,
|
||||
get_example_row_serializer_class,
|
||||
)
|
||||
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 (
|
||||
get_public_view_authorization_token,
|
||||
paginate_and_serialize_queryset,
|
||||
serialize_rows_metadata,
|
||||
serialize_view_field_options,
|
||||
)
|
||||
from baserow.contrib.database.fields.exceptions import (
|
||||
|
@ -105,12 +107,12 @@ class TimelineViewView(APIView):
|
|||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description=(
|
||||
"A comma separated list allowing the values of "
|
||||
"`field_options` which will add the object/objects with the "
|
||||
"same "
|
||||
"A comma separated list allowing the values of `field_options` and "
|
||||
"`row_metadata` which will add the object/objects with the same "
|
||||
"name to the response if included. The `field_options` object "
|
||||
"contains user defined view settings for each field. For "
|
||||
"example the field's width is included in here."
|
||||
"contains user defined view settings for each field. For example "
|
||||
"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,
|
||||
|
@ -152,6 +154,7 @@ class TimelineViewView(APIView):
|
|||
serializer_class=TimelineViewFieldOptionsSerializer,
|
||||
required=False,
|
||||
),
|
||||
"row_metadata": get_example_multiple_rows_metadata_serializer(),
|
||||
},
|
||||
serializer_name="PaginationSerializerWithTimelineViewFieldOptions",
|
||||
),
|
||||
|
@ -185,9 +188,9 @@ class TimelineViewView(APIView):
|
|||
IncompatibleField: ERROR_INCOMPATIBLE_FIELD,
|
||||
}
|
||||
)
|
||||
@allowed_includes("field_options")
|
||||
@allowed_includes("field_options", "row_metadata")
|
||||
@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
|
||||
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:
|
||||
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:
|
||||
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(
|
||||
sender=self,
|
||||
table=view.table,
|
||||
|
|
|
@ -63,6 +63,9 @@ ALL_ROW_COMMENT_NOTIFICATION_MODES = [
|
|||
]
|
||||
|
||||
|
||||
ROW_COMMENT_NOTIFICATION_DEFAULT_MODE = RowCommentsNotificationModes.MODE_ONLY_MENTIONS
|
||||
|
||||
|
||||
class RowCommentsNotificationMode(CreatedAndUpdatedOnMixin, models.Model):
|
||||
"""
|
||||
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_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.",
|
||||
)
|
||||
|
||||
|
|
|
@ -4,9 +4,9 @@ from django.db.models import Count
|
|||
|
||||
from baserow_premium.row_comments.models import (
|
||||
ALL_ROW_COMMENT_NOTIFICATION_MODES,
|
||||
ROW_COMMENT_NOTIFICATION_DEFAULT_MODE,
|
||||
RowComment,
|
||||
RowCommentsNotificationMode,
|
||||
RowCommentsNotificationModes,
|
||||
)
|
||||
from rest_framework import serializers
|
||||
from rest_framework.fields import Field
|
||||
|
@ -38,7 +38,7 @@ class RowCommentCountMetadataType(RowMetadataType):
|
|||
)
|
||||
|
||||
|
||||
class RowCommentsNotificationModeMetadataType(RowCommentCountMetadataType):
|
||||
class RowCommentsNotificationModeMetadataType(RowMetadataType):
|
||||
type = "row_comments_notification_mode"
|
||||
|
||||
def generate_metadata_for_rows(
|
||||
|
@ -61,7 +61,7 @@ class RowCommentsNotificationModeMetadataType(RowCommentCountMetadataType):
|
|||
table=table,
|
||||
row_id__in=row_ids,
|
||||
)
|
||||
.exclude(mode=RowCommentsNotificationModes.MODE_ONLY_MENTIONS)
|
||||
.exclude(mode=ROW_COMMENT_NOTIFICATION_DEFAULT_MODE)
|
||||
.values("row_id", "mode")
|
||||
)
|
||||
}
|
||||
|
|
|
@ -648,3 +648,44 @@ def test_row_comment_notification_type_can_be_rendered_as_email(
|
|||
)
|
||||
== "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"
|
||||
|
|
|
@ -326,13 +326,22 @@ export const actions = {
|
|||
/**
|
||||
* 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(
|
||||
this,
|
||||
tableId,
|
||||
rowId,
|
||||
'row_comments_notification_mode',
|
||||
() => mode.toString()
|
||||
rowMetadataType,
|
||||
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 }
|
||||
)
|
||||
},
|
||||
}
|
||||
|
|
|
@ -21,12 +21,14 @@ import {
|
|||
extractRowReadOnlyValues,
|
||||
prepareNewOldAndUpdateRequestValues,
|
||||
prepareRowForRequest,
|
||||
updateRowMetadataType,
|
||||
getRowMetadata,
|
||||
} from '@baserow/modules/database/utils/row'
|
||||
import { getDefaultSearchModeFromEnv } from '@baserow/modules/database/utils/search'
|
||||
|
||||
export function populateRow(row, metadata = {}) {
|
||||
row._ = {
|
||||
metadata,
|
||||
metadata: getRowMetadata(row, metadata),
|
||||
// Whether the row should be displayed based on the current activeSearchTerm term.
|
||||
matchSearch: true,
|
||||
// Contains the specific field ids which match the activeSearchTerm term.
|
||||
|
@ -212,18 +214,7 @@ export const mutations = {
|
|||
Object.assign(row, values)
|
||||
},
|
||||
UPDATE_ROW_METADATA(state, { 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)
|
||||
}
|
||||
updateRowMetadataType(row, rowMetadataType, updateFunction)
|
||||
},
|
||||
SET_ADHOC_FILTERING(state, adhocFiltering) {
|
||||
state.adhocFiltering = adhocFiltering
|
||||
|
|
|
@ -17,11 +17,13 @@ import {
|
|||
extractRowReadOnlyValues,
|
||||
prepareNewOldAndUpdateRequestValues,
|
||||
prepareRowForRequest,
|
||||
updateRowMetadataType,
|
||||
getRowMetadata,
|
||||
} from '@baserow/modules/database/utils/row'
|
||||
|
||||
export function populateRow(row, metadata = {}) {
|
||||
row._ = {
|
||||
metadata,
|
||||
metadata: getRowMetadata(row, metadata),
|
||||
dragging: false,
|
||||
}
|
||||
return row
|
||||
|
@ -180,18 +182,7 @@ export const mutations = {
|
|||
Object.assign(row, values)
|
||||
},
|
||||
UPDATE_ROW_METADATA(state, { 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)
|
||||
}
|
||||
updateRowMetadataType(row, rowMetadataType, updateFunction)
|
||||
},
|
||||
SET_ADHOC_FILTERING(state, adhocFiltering) {
|
||||
state.adhocFiltering = adhocFiltering
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import bufferedRows from '@baserow/modules/database/store/view/bufferedRows'
|
||||
import TimelineService from '@baserow_premium/services/views/timeline'
|
||||
import { getRowMetadata } from '@baserow/modules/database/utils/row'
|
||||
|
||||
export function populateRow(row, metadata = {}) {
|
||||
row._ = {
|
||||
metadata,
|
||||
metadata: getRowMetadata(row, metadata),
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
@ -32,7 +33,6 @@ export const actions = {
|
|||
fields,
|
||||
initialRowArguments: {
|
||||
includeFieldOptions: true,
|
||||
includeRowMetadata: false,
|
||||
},
|
||||
adhocFiltering,
|
||||
adhocSorting,
|
||||
|
|
|
@ -7,8 +7,13 @@ const groupGetNameCalls = callGrouper(GRACE_DELAY)
|
|||
|
||||
export default (client) => {
|
||||
return {
|
||||
get(tableId, rowId) {
|
||||
return client.get(`/database/rows/table/${tableId}/${rowId}/`)
|
||||
get(tableId, rowId, includeMetadata = true) {
|
||||
const searchParams = {}
|
||||
if (includeMetadata) {
|
||||
searchParams.include = 'metadata'
|
||||
}
|
||||
const params = new URLSearchParams(searchParams).toString()
|
||||
return client.get(`/database/rows/table/${tableId}/${rowId}/?${params}`)
|
||||
},
|
||||
fetchAll({
|
||||
tableId,
|
||||
|
|
|
@ -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
|
||||
* 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 }) {
|
||||
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 = {
|
||||
|
@ -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 = {
|
||||
|
|
|
@ -16,6 +16,8 @@ import {
|
|||
extractRowReadOnlyValues,
|
||||
prepareNewOldAndUpdateRequestValues,
|
||||
prepareRowForRequest,
|
||||
updateRowMetadataType,
|
||||
getRowMetadata,
|
||||
} from '@baserow/modules/database/utils/row'
|
||||
import { getDefaultSearchModeFromEnv } from '@baserow/modules/database/utils/search'
|
||||
import fieldOptionsStoreFactory from '@baserow/modules/database/store/view/fieldOptions'
|
||||
|
@ -59,13 +61,16 @@ export default ({ service, customPopulateRow, fieldOptions }) => {
|
|||
|
||||
const populateRow = (row, metadata = {}) => {
|
||||
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) {
|
||||
row._ = {
|
||||
metadata,
|
||||
metadata: getRowMetadata(row, metadata),
|
||||
}
|
||||
}
|
||||
|
||||
// Matching rows for front-end only search is not yet properly
|
||||
// supported and tested in this store mixin. Only server-side search
|
||||
// implementation is finished.
|
||||
|
@ -270,18 +275,7 @@ export default ({ service, customPopulateRow, fieldOptions }) => {
|
|||
row._.matchSearch = matchSearch
|
||||
},
|
||||
UPDATE_ROW_METADATA(state, { 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)
|
||||
}
|
||||
updateRowMetadataType(row, rowMetadataType, updateFunction)
|
||||
},
|
||||
SET_ADHOC_FILTERING(state, adhocFiltering) {
|
||||
state.adhocFiltering = adhocFiltering
|
||||
|
@ -507,7 +501,8 @@ export default ({ service, customPopulateRow, fieldOptions }) => {
|
|||
})
|
||||
|
||||
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) {
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import bufferedRows from '@baserow/modules/database/store/view/bufferedRows'
|
||||
import GalleryService from '@baserow/modules/database/services/view/gallery'
|
||||
import { getRowMetadata } from '@baserow/modules/database/utils/row'
|
||||
|
||||
export function populateRow(row, metadata = {}) {
|
||||
row._ = {
|
||||
metadata,
|
||||
metadata: getRowMetadata(row, metadata),
|
||||
dragging: false,
|
||||
}
|
||||
return row
|
||||
|
|
|
@ -23,6 +23,8 @@ import {
|
|||
prepareRowForRequest,
|
||||
prepareNewOldAndUpdateRequestValues,
|
||||
extractRowReadOnlyValues,
|
||||
updateRowMetadataType,
|
||||
getRowMetadata,
|
||||
} from '@baserow/modules/database/utils/row'
|
||||
import { getDefaultSearchModeFromEnv } from '@baserow/modules/database/utils/search'
|
||||
import { fieldValuesAreEqualInObjects } from '@baserow/modules/database/utils/groupBy'
|
||||
|
@ -32,7 +34,7 @@ const ORDER_STEP_BEFORE = '0.00000000000000000001'
|
|||
|
||||
export function populateRow(row, metadata = {}) {
|
||||
row._ = {
|
||||
metadata,
|
||||
metadata: getRowMetadata(row, metadata),
|
||||
persistentId: uuid(),
|
||||
loading: false,
|
||||
hover: false,
|
||||
|
@ -50,6 +52,7 @@ export function populateRow(row, metadata = {}) {
|
|||
selected: false,
|
||||
selectedFieldId: -1,
|
||||
}
|
||||
|
||||
return row
|
||||
}
|
||||
|
||||
|
@ -439,18 +442,7 @@ export const mutations = {
|
|||
row[`field_${field.id}`] = value
|
||||
},
|
||||
UPDATE_ROW_METADATA(state, { 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)
|
||||
}
|
||||
updateRowMetadataType(row, rowMetadataType, updateFunction)
|
||||
},
|
||||
FINALIZE_ROWS_IN_BUFFER(state, { oldRows, newRows, fields }) {
|
||||
const stateRowsCopy = { ...state.rows }
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
@ -103,3 +106,29 @@ export function extractRowReadOnlyValues(row, allFields, registry) {
|
|||
})
|
||||
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 || {}) }
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue