1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-05-13 04:41:43 +00:00

Resolve "Allow changing the primary field"

This commit is contained in:
Bram Wiepjes 2024-07-17 18:41:20 +00:00
parent f931be08a1
commit 36890975e7
27 changed files with 892 additions and 63 deletions
backend
src/baserow
contrib
core/locale/en/LC_MESSAGES
tests/baserow/contrib/database
changelog/entries/unreleased/feature
web-frontend
locales
modules

View file

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-06-20 05:25+0000\n"
"POT-Creation-Date: 2024-07-16 13:47+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"
@ -46,13 +46,18 @@ msgstr ""
msgid "Last name"
msgstr ""
#: src/baserow/contrib/builder/data_providers/data_provider_types.py:342
#, python-format
msgid "%(user_source_name)s member"
msgstr ""
#: src/baserow/contrib/builder/data_sources/service.py:128
msgid "Data source"
msgstr ""
#: src/baserow/contrib/builder/elements/mixins.py:289
#: src/baserow/contrib/builder/elements/mixins.py:294
#: src/baserow/contrib/builder/elements/mixins.py:299
#: src/baserow/contrib/builder/elements/mixins.py:309
#: src/baserow/contrib/builder/elements/mixins.py:314
#: src/baserow/contrib/builder/elements/mixins.py:319
#, python-format
msgid "Column %(count)s"
msgstr ""

View file

@ -230,7 +230,7 @@ class AirtableHandler:
baserow_field.name = column["name"]
baserow_field.order = order
baserow_field.primary = (
baserow_field_type.can_be_primary_field
baserow_field_type.can_be_primary_field(baserow_field)
and table["primaryColumnId"] == column["id"]
)
@ -439,7 +439,7 @@ class AirtableHandler:
for value in field_mapping.values():
if field_type_registry.get_by_model(
value["baserow_field"]
).can_be_primary_field:
).can_be_primary_field(value["baserow_field"]):
value["baserow_field"].primary = True
found_existing_field = True
break

View file

@ -152,3 +152,13 @@ ERROR_DATE_FORCE_TIMEZONE_OFFSET_ERROR = (
"must be set to True on the field to convert values with "
"the utc_offset provided in date_force_timezone_offset.",
)
ERROR_FIELD_IS_ALREADY_PRIMARY = (
"ERROR_FIELD_IS_ALREADY_PRIMARY",
HTTP_400_BAD_REQUEST,
"The provided field is already the primary field.",
)
ERROR_TABLE_HAS_NO_PRIMARY_FIELD = (
"ERROR_TABLE_HAS_NO_PRIMARY_FIELD",
HTTP_400_BAD_REQUEST,
"The provided table does not have a primary field.",
)

View file

@ -308,6 +308,12 @@ class DuplicateFieldParamsSerializer(serializers.Serializer):
)
class ChangePrimaryFieldParamsSerializer(serializers.Serializer):
new_primary_field_id = serializers.IntegerField(
help_text="The ID of the new primary field."
)
class ListOrStringField(serializers.ListField):
"""
A serializer field that accept a List or a CSV string that will be converted to

View file

@ -4,6 +4,7 @@ from baserow.contrib.database.fields.registries import field_type_registry
from .views import (
AsyncDuplicateFieldView,
ChangePrimaryFieldView,
FieldsView,
FieldView,
UniqueRowValueFieldView,
@ -13,6 +14,11 @@ app_name = "baserow.contrib.database.api.fields"
urlpatterns = field_type_registry.api_urls + [
re_path(r"table/(?P<table_id>[0-9]+)/$", FieldsView.as_view(), name="list"),
re_path(
r"table/(?P<table_id>[0-9]+)/change-primary-field/$",
ChangePrimaryFieldView.as_view(),
name="change_primary_field",
),
re_path(
r"(?P<field_id>[0-9]+)/unique_row_values/$",
UniqueRowValueFieldView.as_view(),

View file

@ -39,12 +39,16 @@ from baserow.contrib.database.api.fields.errors import (
ERROR_FAILED_TO_LOCK_FIELD_DUE_TO_CONFLICT,
ERROR_FIELD_CIRCULAR_REFERENCE,
ERROR_FIELD_DOES_NOT_EXIST,
ERROR_FIELD_IS_ALREADY_PRIMARY,
ERROR_FIELD_NOT_IN_TABLE,
ERROR_FIELD_SELF_REFERENCE,
ERROR_FIELD_WITH_SAME_NAME_ALREADY_EXISTS,
ERROR_INCOMPATIBLE_FIELD_TYPE_FOR_UNIQUE_VALUES,
ERROR_INCOMPATIBLE_PRIMARY_FIELD_TYPE,
ERROR_INVALID_BASEROW_FIELD_NAME,
ERROR_MAX_FIELD_COUNT_EXCEEDED,
ERROR_RESERVED_BASEROW_FIELD_NAME,
ERROR_TABLE_HAS_NO_PRIMARY_FIELD,
)
from baserow.contrib.database.api.tables.errors import (
ERROR_FAILED_TO_LOCK_TABLE_DUE_TO_CONFLICT,
@ -53,6 +57,7 @@ from baserow.contrib.database.api.tables.errors import (
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.fields.actions import (
ChangePrimaryFieldActionType,
CreateFieldActionType,
DeleteFieldActionType,
UpdateFieldActionType,
@ -67,11 +72,15 @@ from baserow.contrib.database.fields.exceptions import (
CannotDeletePrimaryField,
FailedToLockFieldDueToConflict,
FieldDoesNotExist,
FieldIsAlreadyPrimary,
FieldNotInTable,
FieldWithSameNameAlreadyExists,
IncompatibleFieldTypeForUniqueValues,
IncompatiblePrimaryFieldTypeError,
InvalidBaserowFieldName,
MaxFieldLimitExceeded,
ReservedBaserowFieldNameException,
TableHasNoPrimaryField,
)
from baserow.contrib.database.fields.handler import FieldHandler
from baserow.contrib.database.fields.job_types import DuplicateFieldJobType
@ -99,6 +108,7 @@ from baserow.core.jobs.registries import job_type_registry
from baserow.core.trash.exceptions import CannotDeleteAlreadyDeletedItem
from .serializers import (
ChangePrimaryFieldParamsSerializer,
CreateFieldSerializer,
DuplicateFieldParamsSerializer,
FieldSerializer,
@ -600,3 +610,79 @@ class AsyncDuplicateFieldView(APIView):
serializer = job_type_registry.get_serializer(job, JobSerializer)
return Response(serializer.data, status=status.HTTP_202_ACCEPTED)
class ChangePrimaryFieldView(APIView):
permission_classes = (IsAuthenticated,)
@extend_schema(
parameters=[
OpenApiParameter(
name="table_id",
location=OpenApiParameter.PATH,
type=OpenApiTypes.INT,
description="The table where to update the primary field in.",
),
CLIENT_SESSION_ID_SCHEMA_PARAMETER,
CLIENT_UNDO_REDO_ACTION_GROUP_ID_SCHEMA_PARAMETER,
],
tags=["Database table fields"],
operation_id="change_primary_field",
description=(
"Changes the primary field of a table to the one provided in the body "
"payload."
),
request=ChangePrimaryFieldParamsSerializer,
responses={
200: DiscriminatorCustomFieldsMappingSerializer(
field_type_registry, FieldSerializer
),
400: get_error_schema(
[
"ERROR_USER_NOT_IN_GROUP",
"ERROR_REQUEST_BODY_VALIDATION",
"ERROR_USER_NOT_IN_GROUP",
"ERROR_FIELD_IS_ALREADY_PRIMARY",
"ERROR_FIELD_NOT_IN_TABLE",
"ERROR_INCOMPATIBLE_PRIMARY_FIELD_TYPE",
"ERROR_TABLE_HAS_NO_PRIMARY_FIELD",
]
),
404: get_error_schema(
["ERROR_TABLE_DOES_NOT_EXIST", "ERROR_FIELD_DOES_NOT_EXIST"]
),
},
)
@transaction.atomic
@map_exceptions(
{
TableDoesNotExist: ERROR_TABLE_DOES_NOT_EXIST,
FieldDoesNotExist: ERROR_FIELD_DOES_NOT_EXIST,
UserNotInWorkspace: ERROR_USER_NOT_IN_GROUP,
FieldIsAlreadyPrimary: ERROR_FIELD_IS_ALREADY_PRIMARY,
FieldNotInTable: ERROR_FIELD_NOT_IN_TABLE,
IncompatiblePrimaryFieldTypeError: ERROR_INCOMPATIBLE_PRIMARY_FIELD_TYPE,
TableHasNoPrimaryField: ERROR_TABLE_HAS_NO_PRIMARY_FIELD,
}
)
@validate_body(ChangePrimaryFieldParamsSerializer)
def post(self, request: Request, table_id: int, data: Dict[str, Any]) -> Response:
"""Changes the primary field of the given table."""
# Get the table for update because we want to prevent other fields being
# changed to the primary field.
table = TableHandler().get_table_for_update(table_id)
new_primary_field = FieldHandler().get_specific_field_for_update(
data["new_primary_field_id"]
)
new_primary_field, old_primary_field = action_type_registry.get_by_type(
ChangePrimaryFieldActionType
).do(request.user, table, new_primary_field)
serializer = field_type_registry.get_serializer(
new_primary_field,
FieldSerializerWithRelatedFields,
related_fields=[old_primary_field],
)
return Response(serializer.data)

View file

@ -246,6 +246,7 @@ class DatabaseConfig(AppConfig):
field_converter_registry.register(PasswordFieldConverter())
from .fields.actions import (
ChangePrimaryFieldActionType,
CreateFieldActionType,
DeleteFieldActionType,
DuplicateFieldActionType,
@ -256,6 +257,7 @@ class DatabaseConfig(AppConfig):
action_type_registry.register(DeleteFieldActionType())
action_type_registry.register(UpdateFieldActionType())
action_type_registry.register(DuplicateFieldActionType())
action_type_registry.register(ChangePrimaryFieldActionType())
from .views.view_types import FormViewType, GalleryViewType, GridViewType

View file

@ -17,6 +17,7 @@ from baserow.contrib.database.fields.backup_handler import (
from baserow.contrib.database.fields.handler import FieldHandler
from baserow.contrib.database.fields.models import Field, SpecificFieldForUpdate
from baserow.contrib.database.fields.registries import field_type_registry
from baserow.contrib.database.table.handler import TableHandler
from baserow.contrib.database.table.models import Table
from baserow.core.action.models import Action
from baserow.core.action.registries import (
@ -583,3 +584,84 @@ class DuplicateFieldActionType(UndoableActionType):
TrashHandler.restore_item(
user, "field", params.field_id, parent_trash_item_id=None
)
class ChangePrimaryFieldActionType(UndoableActionType):
type = "change_primary_field"
description = ActionTypeDescription(
_("Change primary field"),
_(
"Primary field of table %(table_name)s was changed to "
"%(new_primary_field_name)s"
),
TABLE_ACTION_CONTEXT,
)
analytics_params = [
"table_id",
"new_primary_field_id",
]
@dataclasses.dataclass
class Params:
database_id: int
database_name: str
table_id: int
table_name: str
new_primary_field_id: int
new_primary_field_name: str
old_primary_field_id: int
old_primary_field_name: str
@classmethod
def do(
cls,
user: AbstractUser,
table: Table,
new_primary_field: Field,
) -> Tuple[Field, Field]:
"""
Change the primary field of the provided table.
:param user: The user on whose behalf the duplicated field will be
changed.
:param table: The table where to change the primary field in.
:param new_primary_field: The field that must be changed to the primary field.
:return: The updated field object.
"""
new_primary_field, old_primary_field = FieldHandler().change_primary_field(
user, table, new_primary_field
)
params = cls.Params(
table.database_id,
table.database.name,
table.id,
table.name,
new_primary_field.id,
new_primary_field.name,
old_primary_field.id,
old_primary_field.name,
)
workspace = table.database.workspace
cls.register_action(user, params, cls.scope(table.id), workspace)
return new_primary_field, old_primary_field
@classmethod
def scope(cls, table_id) -> ActionScopeStr:
return TableActionScopeType.value(table_id)
@classmethod
def undo(cls, user: AbstractUser, params: Params, action_being_undone: Action):
table = TableHandler().get_table_for_update(params.table_id)
field = FieldHandler().get_specific_field_for_update(
params.old_primary_field_id
)
FieldHandler().change_primary_field(user, table, field)
@classmethod
def redo(cls, user: AbstractUser, params: Params, action_being_redone: Action):
table = TableHandler().get_table_for_update(params.table_id)
field = FieldHandler().get_specific_field_for_update(
params.new_primary_field_id
)
FieldHandler().change_primary_field(user, table, field)

View file

@ -264,3 +264,15 @@ class RichTextFieldCannotBePrimaryField(Exception):
"""
Raised when a rich text field is attempted to be set as the primary field.
"""
class FieldIsAlreadyPrimary(Exception):
"""
Raised when the new primary field is already the primary field.
"""
class TableHasNoPrimaryField(Exception):
"""
Raised when the table doesn't have a primary field.
"""

View file

@ -150,7 +150,6 @@ from .exceptions import (
InvalidRollupThroughField,
LinkRowTableNotInSameDatabase,
LinkRowTableNotProvided,
RichTextFieldCannotBePrimaryField,
SelfReferencingLinkRowCannotHaveRelatedField,
)
from .expressions import extract_jsonb_array_values_to_single_string
@ -429,24 +428,14 @@ class LongTextFieldType(CollationSortMixin, FieldType):
def check_can_group_by(self, field: Field) -> bool:
return not field.long_text_enable_rich_text
def before_create(
self, table, primary, allowed_field_values, order, user, field_kwargs
):
# Disallow rich text fields from being primary fields as they can be difficult
# to render in some email notifications, as linked rows and possibly other
# places.
if primary and field_kwargs.get("long_text_enable_rich_text", False):
raise RichTextFieldCannotBePrimaryField(
"A rich text field cannot be the primary field."
)
def before_update(self, from_field, to_field_values, user, field_kwargs):
if from_field.primary and to_field_values.get(
"long_text_enable_rich_text", False
):
raise RichTextFieldCannotBePrimaryField(
"A rich text field cannot be the primary field."
def can_be_primary_field(self, field_or_values: Union[Field, dict]) -> bool:
if isinstance(field_or_values, dict):
enable_rich_text = field_or_values.get("long_text_enable_rich_text", False)
else:
enable_rich_text = getattr(
field_or_values, "long_text_enable_rich_text", False
)
return enable_rich_text is False
def get_serializer_field(self, instance, **kwargs):
required = kwargs.get("required", False)
@ -1995,7 +1984,7 @@ class LinkRowFieldType(ManyToManyFieldTypeSerializeToInputValueMixin, FieldType)
ViewNotInTable: ERROR_VIEW_NOT_IN_TABLE,
}
_can_order_by = False
can_be_primary_field = False
_can_be_primary_field = False
can_get_unique_values = False
is_many_to_many_field = True
@ -6094,7 +6083,7 @@ class PasswordFieldType(FieldType):
can_be_in_form_view = True
keep_data_on_duplication = True
_can_order_by = False
can_be_primary_field = False
_can_be_primary_field = False
can_get_unique_values = False
def get_serializer_field(self, instance, **kwargs):

View file

@ -73,6 +73,8 @@ from .exceptions import (
CannotDeletePrimaryField,
FailedToLockFieldDueToConflict,
FieldDoesNotExist,
FieldIsAlreadyPrimary,
FieldNotInTable,
FieldWithSameNameAlreadyExists,
IncompatibleFieldTypeForUniqueValues,
IncompatiblePrimaryFieldTypeError,
@ -81,6 +83,7 @@ from .exceptions import (
MaxFieldNameLengthExceeded,
PrimaryFieldAlreadyExists,
ReservedBaserowFieldNameException,
TableHasNoPrimaryField,
)
from .field_cache import FieldCache
from .models import Field, SelectOption, SpecificFieldForUpdate
@ -343,6 +346,9 @@ class FieldHandler(metaclass=baserow_trace_methods(tracer)):
field_values = extract_allowed(kwargs, allowed_fields)
last_order = model_class.get_last_order(table)
if primary and not field_type.can_be_primary_field(field_values):
raise IncompatiblePrimaryFieldTypeError(field_type.type)
num_fields = table.field_set.count()
if (num_fields + 1) > settings.MAX_FIELD_LIMIT:
raise MaxFieldLimitExceeded(
@ -494,12 +500,19 @@ class FieldHandler(metaclass=baserow_trace_methods(tracer)):
# migrate the field to the new type.
baserow_field_type_changed = from_field_type.type != to_field_type_name
field_cache = FieldCache()
if baserow_field_type_changed:
to_field_type = field_type_registry.get(to_field_type_name)
else:
to_field_type = from_field_type
if field.primary and not to_field_type.can_be_primary_field:
raise IncompatiblePrimaryFieldTypeError(to_field_type_name)
allowed_fields = ["name", "description"] + to_field_type.allowed_fields
field_values = extract_allowed(kwargs, allowed_fields)
if field.primary and not to_field_type.can_be_primary_field(field_values):
raise IncompatiblePrimaryFieldTypeError(to_field_type_name)
if baserow_field_type_changed:
dependants_broken_due_to_type_change = (
from_field_type.get_dependants_which_will_break_when_field_type_changes(
field, to_field_type, field_cache
@ -510,10 +523,6 @@ class FieldHandler(metaclass=baserow_trace_methods(tracer)):
else:
dependants_broken_due_to_type_change = []
to_field_type = from_field_type
allowed_fields = ["name", "description"] + to_field_type.allowed_fields
field_values = extract_allowed(kwargs, allowed_fields)
self._validate_name_and_optionally_rename_if_collision(
field, field_values, postfix_to_fix_name_collisions
@ -1242,6 +1251,78 @@ class FieldHandler(metaclass=baserow_trace_methods(tracer)):
return [x[0] for x in res]
def change_primary_field(
self, user: AbstractUser, table: Table, new_primary_field: Field
) -> Tuple[Field, Field]:
"""
Changes the primary field of the given table.
:param user: The user on whose behalf the duplicated field will be
changed.
:param table: The table where to change the primary field in.
:param new_primary_field: The field that must be changed to the primary field.
:raises FieldNotInTable:
:raises IncompatiblePrimaryFieldTypeError:
:raises FieldIsAlreadyPrimary:
:return: The updated field object.
"""
if not isinstance(new_primary_field, Field):
raise ValueError("The field is not an instance of Field.")
if type(new_primary_field) is Field:
raise ValueError(
"The field must be a specific instance of Field and not the base type "
"Field itself."
)
workspace = table.database.workspace
CoreHandler().check_permissions(
user,
UpdateFieldOperationType.type,
workspace=workspace,
context=new_primary_field,
)
if new_primary_field.table_id != table.id:
raise FieldNotInTable(
"The provided new primary field does not belong in the provided table."
)
new_primary_field_type = field_type_registry.get_by_model(new_primary_field)
if not new_primary_field_type.can_be_primary_field(new_primary_field):
raise IncompatiblePrimaryFieldTypeError(new_primary_field_type.type)
existing_primary_fields = self.get_fields(
table,
Field.objects.filter(table=table, primary=True).select_for_update(),
specific=True,
)
existing_primary_field = next(iter(existing_primary_fields), None)
if existing_primary_field is None:
raise TableHasNoPrimaryField("The provided table has no primary field.")
if existing_primary_field.id == new_primary_field.id:
raise FieldIsAlreadyPrimary("The provided field is already primary.")
existing_primary_field.primary = False
existing_primary_field.save()
old_new_primary_field = deepcopy(new_primary_field)
new_primary_field.primary = True
new_primary_field.save()
field_updated.send(
self,
field=new_primary_field,
old_field=old_new_primary_field,
related_fields=[existing_primary_field],
user=user,
)
return new_primary_field, existing_primary_field
def _validate_name_and_optionally_rename_if_collision(
self,
field: Field,

View file

@ -105,7 +105,7 @@ class FieldType(
_can_order_by = True
"""Indicates whether it is possible to order by this field type."""
can_be_primary_field = True
_can_be_primary_field = True
"""Some field types cannot be the primary field."""
can_have_select_options = False
@ -1489,6 +1489,18 @@ class FieldType(
via_path_to_starting_table,
)
def can_be_primary_field(self, field_or_values: Union[Field, dict]) -> bool:
"""
Override this method if this field type can't be primary.
:param field_or_values: The field object or the values to create/update it.
It accepts either because in some cases the field object doesn't exist,
but we do need to check if the field can be primary.
:return: True if the field can be primary.
"""
return self._can_be_primary_field
def check_can_order_by(self, field: Field) -> bool:
"""
Override this method if this field type can sometimes be ordered or sometimes

View file

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-06-20 05:25+0000\n"
"POT-Creation-Date: 2024-07-16 13:47+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"
@ -68,46 +68,57 @@ msgstr ""
msgid "Table \"%(table_name)s\" (%(table_id)s) exported to %(export_type)s"
msgstr ""
#: src/baserow/contrib/database/fields/actions.py:35
#: src/baserow/contrib/database/fields/actions.py:36
msgid "Update field"
msgstr ""
#: src/baserow/contrib/database/fields/actions.py:36
#: src/baserow/contrib/database/fields/actions.py:37
#, python-format
msgid "Field \"%(field_name)s\" (%(field_id)s) updated"
msgstr ""
#: src/baserow/contrib/database/fields/actions.py:339
#: src/baserow/contrib/database/fields/actions.py:340
msgid "Create field"
msgstr ""
#: src/baserow/contrib/database/fields/actions.py:340
#: src/baserow/contrib/database/fields/actions.py:341
#, python-format
msgid "Field \"%(field_name)s\" (%(field_id)s) created"
msgstr ""
#: src/baserow/contrib/database/fields/actions.py:433
#: src/baserow/contrib/database/fields/actions.py:434
msgid "Delete field"
msgstr ""
#: src/baserow/contrib/database/fields/actions.py:434
#: src/baserow/contrib/database/fields/actions.py:435
#, python-format
msgid "Field \"%(field_name)s\" (%(field_id)s) deleted"
msgstr ""
#: src/baserow/contrib/database/fields/actions.py:506
#: src/baserow/contrib/database/fields/actions.py:507
msgid "Duplicate field"
msgstr ""
#: src/baserow/contrib/database/fields/actions.py:508
#: src/baserow/contrib/database/fields/actions.py:509
#, python-format
msgid ""
"Field \"%(field_name)s\" (%(field_id)s) duplicated (with_data=%(with_data)s) "
"from field \"%(original_field_name)s\" (%(original_field_id)s)"
msgstr ""
#: src/baserow/contrib/database/fields/actions.py:592
msgid "Change primary field"
msgstr ""
#: src/baserow/contrib/database/fields/actions.py:594
#, python-format
msgid ""
"Primary field of table %(table_name)s was changed to "
"%(new_primary_field_name)s"
msgstr ""
#: src/baserow/contrib/database/fields/models.py:371
#: src/baserow/contrib/database/fields/models.py:510
#: src/baserow/contrib/database/fields/models.py:519
msgid "The format of the duration."
msgstr ""
@ -136,8 +147,8 @@ msgstr ""
#: src/baserow/contrib/database/plugins.py:55
#: src/baserow/contrib/database/plugins.py:77
#: src/baserow/contrib/database/table/handler.py:623
#: src/baserow/contrib/database/table/handler.py:636
#: src/baserow/contrib/database/table/handler.py:624
#: src/baserow/contrib/database/table/handler.py:637
msgid "Name"
msgstr ""
@ -146,13 +157,13 @@ msgid "Last name"
msgstr ""
#: src/baserow/contrib/database/plugins.py:57
#: src/baserow/contrib/database/table/handler.py:624
#: src/baserow/contrib/database/table/handler.py:625
msgid "Notes"
msgstr ""
#: src/baserow/contrib/database/plugins.py:58
#: src/baserow/contrib/database/plugins.py:79
#: src/baserow/contrib/database/table/handler.py:625
#: src/baserow/contrib/database/table/handler.py:626
msgid "Active"
msgstr ""
@ -300,11 +311,11 @@ msgid ""
"\"%(original_table_name)s\" (%(original_table_id)s) "
msgstr ""
#: src/baserow/contrib/database/table/handler.py:527
#: src/baserow/contrib/database/table/handler.py:528
msgid "Grid"
msgstr ""
#: src/baserow/contrib/database/table/handler.py:585
#: src/baserow/contrib/database/table/handler.py:586
#, python-format
msgid "Field %d"
msgstr ""

View file

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-06-20 07:22+0000\n"
"POT-Creation-Date: 2024-07-16 13:47+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"
@ -215,16 +215,16 @@ msgstr ""
msgid "You have %(count)d new notifications - Baserow"
msgstr ""
#: src/baserow/core/fields.py:112
#: src/baserow/core/fields.py:114
#, python-format
msgid "“%(value)s” value must be a decimal number."
msgstr ""
#: src/baserow/core/fields.py:114
#: src/baserow/core/fields.py:116
msgid "Decimal number"
msgstr ""
#: src/baserow/core/handler.py:2039 src/baserow/core/user/handler.py:266
#: src/baserow/core/handler.py:2053 src/baserow/core/user/handler.py:268
#, python-format
msgid "%(name)s's workspace"
msgstr ""
@ -536,8 +536,3 @@ msgstr ""
#: src/baserow/core/user/emails.py:74
msgid "Account deletion cancelled - Baserow"
msgstr ""
#: src/baserow/core/user_sources/user_source_user.py:81
#, python-format
msgid "%(user_source_name)s member"
msgstr ""

View file

@ -953,3 +953,175 @@ def test_async_duplicate_field(api_client, data_fixture):
assert field_set.count() == original_field_count + 2
for row in response_json["results"]:
assert row[f"{primary_field.name} 3"] == row[primary_field.name]
@pytest.mark.django_db
def test_change_primary_field_different_table(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
table_a = data_fixture.create_database_table(user)
field_1 = data_fixture.create_text_field(user=user, primary=True, table=table_a)
field_2 = data_fixture.create_text_field(user=user, primary=False, table=table_a)
table_b = data_fixture.create_database_table(user)
response = api_client.post(
reverse(
"api:database:fields:change_primary_field", kwargs={"table_id": table_b.id}
),
{"new_primary_field_id": field_2.id},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["error"] == "ERROR_FIELD_NOT_IN_TABLE"
@pytest.mark.django_db
def test_change_primary_field_type_not_primary(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
table_a = data_fixture.create_database_table(user)
field_1 = data_fixture.create_text_field(user=user, primary=True, table=table_a)
field_2 = data_fixture.create_password_field(
user=user, primary=False, table=table_a
)
response = api_client.post(
reverse(
"api:database:fields:change_primary_field", kwargs={"table_id": table_a.id}
),
{"new_primary_field_id": field_2.id},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["error"] == "ERROR_INCOMPATIBLE_PRIMARY_FIELD_TYPE"
@pytest.mark.django_db
def test_change_primary_field_field_is_already_primary(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
table_a = data_fixture.create_database_table(user)
field_1 = data_fixture.create_text_field(user=user, primary=True, table=table_a)
response = api_client.post(
reverse(
"api:database:fields:change_primary_field", kwargs={"table_id": table_a.id}
),
{"new_primary_field_id": field_1.id},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["error"] == "ERROR_FIELD_IS_ALREADY_PRIMARY"
@pytest.mark.django_db
def test_change_primary_field_field_no_update_permissions(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
user_2, token_2 = data_fixture.create_user_and_token()
table_a = data_fixture.create_database_table(user)
field_1 = data_fixture.create_text_field(user=user, primary=True, table=table_a)
field_2 = data_fixture.create_text_field(user=user, primary=False, table=table_a)
response = api_client.post(
reverse(
"api:database:fields:change_primary_field", kwargs={"table_id": table_a.id}
),
{"new_primary_field_id": field_2.id},
format="json",
HTTP_AUTHORIZATION=f"JWT {token_2}",
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["error"] == "ERROR_USER_NOT_IN_GROUP"
@pytest.mark.django_db
def test_change_primary_field_field_without_primary(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
table_a = data_fixture.create_database_table(user)
field_2 = data_fixture.create_text_field(user=user, primary=False, table=table_a)
response = api_client.post(
reverse(
"api:database:fields:change_primary_field", kwargs={"table_id": table_a.id}
),
{"new_primary_field_id": field_2.id},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["error"] == "ERROR_TABLE_HAS_NO_PRIMARY_FIELD"
@pytest.mark.django_db
def test_change_primary_field_field_with_primary(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
table_a = data_fixture.create_database_table(user)
field_1 = data_fixture.create_text_field(user=user, primary=True, table=table_a)
field_2 = data_fixture.create_text_field(user=user, primary=False, table=table_a)
response = api_client.post(
reverse(
"api:database:fields:change_primary_field", kwargs={"table_id": table_a.id}
),
{"new_primary_field_id": field_2.id},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_200_OK
assert response.json() == {
"id": field_2.id,
"table_id": table_a.id,
"name": field_2.name,
"order": 0,
"type": "text",
"primary": True,
"read_only": False,
"description": None,
"related_fields": [
{
"id": field_1.id,
"table_id": table_a.id,
"name": field_1.name,
"order": 0,
"type": "text",
"primary": False,
"read_only": False,
"description": None,
"text_default": "",
}
],
"text_default": "",
}
@pytest.mark.django_db
def test_change_primary_field_field_and_back(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
table_a = data_fixture.create_database_table(user)
field_1 = data_fixture.create_text_field(user=user, primary=True, table=table_a)
field_2 = data_fixture.create_text_field(user=user, primary=False, table=table_a)
response = api_client.post(
reverse(
"api:database:fields:change_primary_field", kwargs={"table_id": table_a.id}
),
{"new_primary_field_id": field_2.id},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_200_OK
response = api_client.post(
reverse(
"api:database:fields:change_primary_field", kwargs={"table_id": table_a.id}
),
{"new_primary_field_id": field_1.id},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_200_OK
field_1.refresh_from_db()
field_2.refresh_from_db()
assert field_1.primary is True
assert field_2.primary is False

View file

@ -10,6 +10,7 @@ from pytest_unordered import unordered
from rest_framework.status import HTTP_200_OK
from baserow.contrib.database.fields.actions import (
ChangePrimaryFieldActionType,
DuplicateFieldActionType,
UpdateFieldActionType,
)
@ -1490,3 +1491,46 @@ def test_date_field_type_undo_redo_fix_timezone_offset(api_client, data_fixture)
assert getattr(
row_3, f"field_{datetime_field.id}"
) == original_datetime_3 + timedelta(minutes=utc_offset)
@pytest.mark.django_db
@pytest.mark.undo_redo
@pytest.mark.field_single_select
def test_can_undo_redo_change_primary_field(data_fixture):
session_id = "session-id"
user = data_fixture.create_user(session_id=session_id)
table_a = data_fixture.create_database_table(user)
field_1 = data_fixture.create_text_field(user=user, primary=True, table=table_a)
field_2 = data_fixture.create_text_field(user=user, primary=False, table=table_a)
new_primary_field, old_primary_field = action_type_registry.get_by_type(
ChangePrimaryFieldActionType
).do(user, table_a, field_2)
assert new_primary_field.id == field_2.id
assert old_primary_field.id == field_1.id
field_1.refresh_from_db()
field_2.refresh_from_db()
assert field_1.primary is False
assert field_2.primary is True
actions = ActionHandler.undo(
user, [ChangePrimaryFieldActionType.scope(table_a.id)], session_id
)
assert_undo_redo_actions_are_valid(actions, [ChangePrimaryFieldActionType])
field_1.refresh_from_db()
field_2.refresh_from_db()
assert field_1.primary is True
assert field_2.primary is False
actions = ActionHandler.redo(
user, [ChangePrimaryFieldActionType.scope(table_a.id)], session_id
)
assert_undo_redo_actions_are_valid(actions, [ChangePrimaryFieldActionType])
field_1.refresh_from_db()
field_2.refresh_from_db()
assert field_1.primary is False
assert field_2.primary is True

View file

@ -14,6 +14,8 @@ from baserow.contrib.database.fields.exceptions import (
CannotChangeFieldType,
CannotDeletePrimaryField,
FieldDoesNotExist,
FieldIsAlreadyPrimary,
FieldNotInTable,
FieldTypeDoesNotExist,
FieldWithSameNameAlreadyExists,
IncompatibleFieldTypeForUniqueValues,
@ -22,6 +24,7 @@ from baserow.contrib.database.fields.exceptions import (
MaxFieldNameLengthExceeded,
PrimaryFieldAlreadyExists,
ReservedBaserowFieldNameException,
TableHasNoPrimaryField,
)
from baserow.contrib.database.fields.field_helpers import (
construct_all_possible_field_kwargs,
@ -1733,3 +1736,117 @@ def test_duplicating_link_row_field_properly_resets_pk_sequence_of_new_table(
)
assert table_b_row_1.id in linked_vals
assert table_b_row_2.id in linked_vals
@pytest.mark.django_db
def test_change_primary_field_different_table(data_fixture):
user = data_fixture.create_user()
table_a = data_fixture.create_database_table(user)
field_1 = data_fixture.create_text_field(user=user, primary=True, table=table_a)
field_2 = data_fixture.create_text_field(user=user, primary=False, table=table_a)
table_b = data_fixture.create_database_table(user)
with pytest.raises(FieldNotInTable):
FieldHandler().change_primary_field(user, table_b, field_2)
@pytest.mark.django_db
def test_change_primary_field_type_not_primary(data_fixture):
user = data_fixture.create_user()
table_a = data_fixture.create_database_table(user)
field_1 = data_fixture.create_text_field(user=user, primary=True, table=table_a)
field_2 = data_fixture.create_password_field(
user=user, primary=False, table=table_a
)
with pytest.raises(IncompatiblePrimaryFieldTypeError):
FieldHandler().change_primary_field(user, table_a, field_2)
@pytest.mark.django_db
def test_change_primary_field_field_is_already_primary(data_fixture):
user = data_fixture.create_user()
table_a = data_fixture.create_database_table(user)
field_1 = data_fixture.create_text_field(user=user, primary=True, table=table_a)
with pytest.raises(FieldIsAlreadyPrimary):
FieldHandler().change_primary_field(user, table_a, field_1)
@pytest.mark.django_db
def test_change_primary_field_field_no_update_permissions(data_fixture):
user = data_fixture.create_user()
user_2 = data_fixture.create_user()
table_a = data_fixture.create_database_table(user)
field_1 = data_fixture.create_text_field(user=user, primary=True, table=table_a)
field_2 = data_fixture.create_text_field(user=user, primary=False, table=table_a)
with pytest.raises(UserNotInWorkspace):
FieldHandler().change_primary_field(user_2, table_a, field_2)
@pytest.mark.django_db
def test_change_primary_field_field_without_primary(data_fixture):
user = data_fixture.create_user()
table_a = data_fixture.create_database_table(user)
field_2 = data_fixture.create_text_field(user=user, primary=False, table=table_a)
with pytest.raises(TableHasNoPrimaryField):
FieldHandler().change_primary_field(user, table_a, field_2)
@pytest.mark.django_db
def test_change_primary_field_field_with_primary(data_fixture):
user = data_fixture.create_user()
table_a = data_fixture.create_database_table(user)
field_1 = data_fixture.create_text_field(user=user, primary=True, table=table_a)
field_2 = data_fixture.create_text_field(user=user, primary=False, table=table_a)
new_primary, old_primary = FieldHandler().change_primary_field(
user, table_a, field_2
)
assert new_primary.id == field_2.id
assert new_primary.primary is True
assert old_primary.id == field_1.id
assert old_primary.primary is False
@pytest.mark.django_db
def test_change_primary_field_field_with_existing_primary_field(data_fixture):
user = data_fixture.create_user()
table_a = data_fixture.create_database_table(user)
field_1 = data_fixture.create_text_field(user=user, primary=True, table=table_a)
field_2 = data_fixture.create_text_field(user=user, primary=False, table=table_a)
data_fixture.create_text_field(user=user, primary=True)
new_primary, old_primary = FieldHandler().change_primary_field(
user, table_a, field_2
)
assert new_primary.id == field_2.id
assert new_primary.primary is True
assert old_primary.id == field_1.id
assert old_primary.primary is False
@pytest.mark.django_db
@patch("baserow.contrib.database.fields.signals.field_updated.send")
def test_change_primary_field_signal_send(send_mock, data_fixture):
user = data_fixture.create_user()
table_a = data_fixture.create_database_table(user)
field_1 = data_fixture.create_text_field(user=user, primary=True, table=table_a)
field_2 = data_fixture.create_text_field(user=user, primary=False, table=table_a)
new_primary, old_primary = FieldHandler().change_primary_field(
user, table_a, field_2
)
send_mock.assert_called_once()
assert send_mock.call_args[1]["user"].id == user.id
assert send_mock.call_args[1]["field"].id == field_2.id
assert send_mock.call_args[1]["field"].primary is True
assert send_mock.call_args[1]["old_field"].id == field_2.id
assert send_mock.call_args[1]["old_field"].primary is False
assert send_mock.call_args[1]["related_fields"][0].id == field_1.id
assert send_mock.call_args[1]["related_fields"][0].primary is False

View file

@ -1,6 +1,6 @@
import pytest
from baserow.contrib.database.fields.exceptions import RichTextFieldCannotBePrimaryField
from baserow.contrib.database.fields.exceptions import IncompatiblePrimaryFieldTypeError
from baserow.contrib.database.fields.handler import FieldHandler
from baserow.contrib.database.rows.handler import RowHandler
from baserow.contrib.database.table.models import RichTextFieldMention
@ -15,7 +15,7 @@ def test_rich_text_field_cannot_be_primary(data_fixture):
database = data_fixture.create_database_application(user=user)
table = data_fixture.create_database_table(database=database)
with pytest.raises(RichTextFieldCannotBePrimaryField):
with pytest.raises(IncompatiblePrimaryFieldTypeError):
FieldHandler().create_field(
user=user,
table=table,
@ -30,7 +30,7 @@ def test_rich_text_field_cannot_be_primary(data_fixture):
user=user, table=table, type_name="long_text", name="Primary", primary=True
)
with pytest.raises(RichTextFieldCannotBePrimaryField):
with pytest.raises(IncompatiblePrimaryFieldTypeError):
FieldHandler().update_field(
user=user,
field=primary_field,
@ -38,6 +38,20 @@ def test_rich_text_field_cannot_be_primary(data_fixture):
long_text_enable_rich_text=True,
)
rich_text = FieldHandler().create_field(
user=user,
table=table,
type_name="long_text",
name="Rich text",
primary=False,
long_text_enable_rich_text=True,
)
with pytest.raises(IncompatiblePrimaryFieldTypeError):
FieldHandler().change_primary_field(
user=user, table=table, new_primary_field=rich_text
)
@pytest.mark.django_db
def test_perm_deleting_rows_delete_rich_text_mentions(data_fixture):

View file

@ -0,0 +1,7 @@
{
"type": "feature",
"message": "Allow changing the primary field.",
"issue_number": 1301,
"bullet_points": [],
"created_at": "2024-07-16"
}

View file

@ -369,7 +369,11 @@
"generateAIPromptTitle": "Prompt error",
"generateAIPromptDescription": "Something was wrong with the constructed prompt.",
"maxNumberOfPendingWorkspaceInvitesReachedTitle": "Max pending invites reached",
"maxNumberOfPendingWorkspaceInvitesReachedDescription": "You've reached the maximum number of pending invites for this workspace. Please let invitees accept the invite or cancel existing ones to continue."
"maxNumberOfPendingWorkspaceInvitesReachedDescription": "You've reached the maximum number of pending invites for this workspace. Please let invitees accept the invite or cancel existing ones to continue.",
"fieldIsAlreadyPrimaryTitle": "Field is already primary",
"fieldIsAlreadyPrimaryDescription": "The chose new primary field is already the primary field.",
"incompatiblePrimaryFieldTypeTitle": "Field is not compatible",
"incompatiblePrimaryFieldTypeDescription": "The chose field can't be the primary field because it's incompatible."
},
"importerType": {
"csv": "Import a CSV file",

View file

@ -172,6 +172,14 @@ export class ClientErrorMap {
'clientHandler.maxNumberOfPendingWorkspaceInvitesReachedDescription'
)
),
ERROR_FIELD_IS_ALREADY_PRIMARY: new ResponseErrorMessage(
app.i18n.t('clientHandler.fieldIsAlreadyPrimaryTitle'),
app.i18n.t('clientHandler.fieldIsAlreadyPrimaryDescription')
),
ERROR_INCOMPATIBLE_PRIMARY_FIELD_TYPE: new ResponseErrorMessage(
app.i18n.t('clientHandler.incompatiblePrimaryFieldTypeTitle'),
app.i18n.t('clientHandler.incompatiblePrimaryFieldTypeDescription')
),
}
}

View file

@ -0,0 +1,98 @@
<template>
<Modal :small="true" @show="hideError()">
<h2 class="box__title">
{{ $t('changePrimaryFieldModal.title') }}
</h2>
<Error :error="error"></Error>
<form @submit.prevent="changePrimaryField()">
<FormGroup
:label="$t('changePrimaryFieldModal.primaryFieldLabel')"
small-label
required
:helper-text="
$t('changePrimaryFieldModal.existingPrimary', {
name: fromField.name,
})
"
>
<Dropdown v-model="newPrimaryFieldId">
<DropdownItem
v-for="field in newPrimaryFields"
:key="'field-' + field.id"
:name="field.name"
:value="field.id"
:icon="field._.type.iconClass"
:disabled="!field._.type.canBePrimaryField"
></DropdownItem>
</Dropdown>
</FormGroup>
<div class="actions actions--right">
<Button
ref="submitButton"
type="primary"
:loading="loading"
:disabled="loading"
>
{{ $t('changePrimaryFieldModal.change') }}
</Button>
</div>
</form>
</Modal>
</template>
<script>
import error from '@baserow/modules/core/mixins/error'
import modal from '@baserow/modules/core/mixins/modal'
export default {
name: 'ChangePrimaryFieldModal',
mixins: [modal, error],
props: {
table: {
type: Object,
required: true,
},
fromField: {
type: Object,
required: true,
},
allFieldsInTable: {
type: Array,
required: true,
},
},
data() {
return {
loading: false,
newPrimaryFieldId: this.fromField.id,
}
},
computed: {
newPrimaryFields() {
return this.allFieldsInTable.filter((field) => !field.primary)
},
},
methods: {
async changePrimaryField() {
if (this.loading || this.disabled) {
return
}
this.loading = true
this.hideError()
const newPrimaryField = this.allFieldsInTable.find(
(field) => field.id === this.newPrimaryFieldId
)
try {
await this.$store.dispatch('field/changePrimary', {
field: newPrimaryField,
})
this.newPrimaryFieldId = null
this.hide()
} catch (error) {
this.handleError(error)
}
this.loading = false
},
},
}
</script>

View file

@ -42,6 +42,31 @@
@updated="$refs.context.hide()"
></UpdateFieldContext>
</li>
<li
v-if="
field.primary &&
$hasPermission(
'database.table.field.update',
field,
database.workspace.id
)
"
class="context__menu-item"
>
<a
class="context__menu-item-link"
@click="$refs.changePrimaryFieldModal.show()"
>
<i class="context__menu-item-icon iconoir-coins-swap"></i>
{{ $t('fieldContext.changePrimaryField') }}
</a>
<ChangePrimaryFieldModal
ref="changePrimaryFieldModal"
:all-fields-in-table="allFieldsInTable"
:from-field="field"
:table="table"
></ChangePrimaryFieldModal>
</li>
<slot></slot>
<li
v-if="
@ -71,10 +96,12 @@
import context from '@baserow/modules/core/mixins/context'
import UpdateFieldContext from '@baserow/modules/database/components/field/UpdateFieldContext'
import { notifyIf } from '@baserow/modules/core/utils/error'
import ChangePrimaryFieldModal from '@baserow/modules/database/components/field/ChangePrimaryFieldModal.vue'
export default {
name: 'FieldContext',
components: {
ChangePrimaryFieldModal,
UpdateFieldContext,
},
mixins: [context],

View file

@ -450,6 +450,7 @@ export class FieldType extends Registerable {
name: this.getName(),
isReadOnly: this.isReadOnly,
canImport: this.getCanImport(),
canBePrimaryField: this.canBePrimaryField,
}
}

View file

@ -279,7 +279,8 @@
"editField": "Edit field",
"deleteField": "Delete field",
"hideField": "Hide field",
"showField": "Show field"
"showField": "Show field",
"changePrimaryField": "Change primary field"
},
"fieldForm": {
"name": "Name",
@ -754,6 +755,12 @@
"cloneData": "Copy data",
"readOnlyField": "Cell values will be filled automatically."
},
"changePrimaryFieldModal": {
"title": "Change the primary field",
"change": "Change",
"primaryFieldLabel": "Primary field",
"existingPrimary": "\"{name}\" is currently the primary field."
},
"snapshotsModal": {
"title": "snapshots",
"description": "Snapshots are a full copy of your {applicationTypeName} of the moment when they were created. A duplication of that data will be created when restoring. Snapshots are automatically deleted after one year.",

View file

@ -46,5 +46,13 @@ export default (client) => {
config
)
},
changePrimary(tableId, newPrimaryFieldId) {
return client.post(
`/database/fields/table/${tableId}/change-primary-field/`,
{
new_primary_field_id: newPrimaryFieldId,
}
)
},
}
}

View file

@ -223,6 +223,31 @@ export const actions = {
return forceUpdate ? await forceUpdateCallback() : forceUpdateCallback
},
/**
* Promote the provided field to primary field.
*/
async changePrimary(context, { field, forceUpdate = true }) {
const { dispatch } = context
const newField = clone(field)
const oldField = clone(field)
newField.primary = true
const { data } = await FieldService(this.$client).changePrimary(
field.table_id,
field.id
)
const forceUpdateCallback = async () => {
return await dispatch('forceUpdate', {
field,
oldField,
data,
relatedFields: data.related_fields,
})
}
return forceUpdate ? await forceUpdateCallback() : forceUpdateCallback
},
/**
* Forcefully update an existing field without making a request to the backend.
*/