diff --git a/backend/src/baserow/contrib/database/api/views/errors.py b/backend/src/baserow/contrib/database/api/views/errors.py index 23bf44941..93cad87b9 100644 --- a/backend/src/baserow/contrib/database/api/views/errors.py +++ b/backend/src/baserow/contrib/database/api/views/errors.py @@ -61,3 +61,8 @@ ERROR_VIEW_DOES_NOT_SUPPORT_FIELD_OPTIONS = ( HTTP_400_BAD_REQUEST, "This view model does not support field options.", ) +ERROR_CANNOT_SHARE_VIEW_TYPE = ( + "ERROR_CANNOT_SHARE_VIEW_TYPE", + HTTP_400_BAD_REQUEST, + "This view type does not support sharing.", +) diff --git a/backend/src/baserow/contrib/database/api/views/form/urls.py b/backend/src/baserow/contrib/database/api/views/form/urls.py index c3b209375..bde58c2c2 100644 --- a/backend/src/baserow/contrib/database/api/views/form/urls.py +++ b/backend/src/baserow/contrib/database/api/views/form/urls.py @@ -1,20 +1,13 @@ from django.urls import re_path from .views import ( - RotateFormViewSlugView, SubmitFormViewView, FormViewLinkRowFieldLookupView, ) - app_name = "baserow.contrib.database.api.views.form" urlpatterns = [ - re_path( - r"(?P<view_id>[0-9]+)/rotate-slug/$", - RotateFormViewSlugView.as_view(), - name="rotate_slug", - ), re_path( r"(?P<slug>[-\w]+)/submit/$", SubmitFormViewView.as_view(), diff --git a/backend/src/baserow/contrib/database/api/views/form/views.py b/backend/src/baserow/contrib/database/api/views/form/views.py index b85caae01..59a3541d4 100644 --- a/backend/src/baserow/contrib/database/api/views/form/views.py +++ b/backend/src/baserow/contrib/database/api/views/form/views.py @@ -1,6 +1,6 @@ from drf_spectacular.openapi import OpenApiParameter, OpenApiTypes from drf_spectacular.utils import extend_schema -from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.fields import empty @@ -9,12 +9,10 @@ from django.contrib.contenttypes.models import ContentType from django.conf import settings from baserow.api.decorators import map_exceptions -from baserow.api.errors import ERROR_USER_NOT_IN_GROUP from baserow.api.schemas import get_error_schema from baserow.api.utils import validate_data from baserow.api.pagination import PageNumberPagination from baserow.api.serializers import get_example_pagination_serializer_class -from baserow.contrib.database.api.views.errors import ERROR_VIEW_DOES_NOT_EXIST from baserow.contrib.database.api.rows.serializers import ( get_row_serializer_class, get_example_row_serializer_class, @@ -25,60 +23,12 @@ from baserow.contrib.database.fields.models import LinkRowField from baserow.contrib.database.fields.exceptions import FieldDoesNotExist from baserow.contrib.database.views.exceptions import ViewDoesNotExist from baserow.contrib.database.views.handler import ViewHandler -from baserow.contrib.database.views.models import FormView, FormViewFieldOptions -from baserow.contrib.database.views.registries import view_type_registry +from baserow.contrib.database.views.models import FormViewFieldOptions, FormView from baserow.contrib.database.views.validators import required_validator -from baserow.core.exceptions import UserNotInGroup from .errors import ERROR_FORM_DOES_NOT_EXIST from .serializers import PublicFormViewSerializer, FormViewSubmittedSerializer -form_view_serializer_class = view_type_registry.get("form").get_serializer_class() - - -class RotateFormViewSlugView(APIView): - permission_classes = (IsAuthenticated,) - - @extend_schema( - parameters=[ - OpenApiParameter( - name="view_id", - location=OpenApiParameter.PATH, - type=OpenApiTypes.INT, - required=True, - description="Rotates the slug of the form view related to the provided " - "value.", - ) - ], - tags=["Database table form view"], - operation_id="rotate_database_table_form_view_slug", - description=( - "Rotates the unique slug of the form view by replacing it with a new " - "value. This would mean that the publicly shared URL of the form will " - "change. Everyone that knew the URL won't have access to the form anymore." - ), - request=None, - responses={ - 200: form_view_serializer_class(many=True), - 400: get_error_schema(["ERROR_USER_NOT_IN_GROUP"]), - 404: get_error_schema(["ERROR_VIEW_DOES_NOT_EXIST"]), - }, - ) - @map_exceptions( - { - UserNotInGroup: ERROR_USER_NOT_IN_GROUP, - ViewDoesNotExist: ERROR_VIEW_DOES_NOT_EXIST, - } - ) - @transaction.atomic - def post(self, request, view_id): - """Rotates the slug of a form view.""" - - handler = ViewHandler() - form = ViewHandler().get_view(view_id, FormView) - form = handler.rotate_form_view_slug(request.user, form) - return Response(form_view_serializer_class(form).data) - class SubmitFormViewView(APIView): permission_classes = (AllowAny,) @@ -111,7 +61,9 @@ class SubmitFormViewView(APIView): } ) def get(self, request, slug): - form = ViewHandler().get_public_form_view_by_slug(request.user, slug) + form = ViewHandler().get_public_view_by_slug( + request.user, slug, view_model=FormView + ) serializer = PublicFormViewSerializer(form) return Response(serializer.data) @@ -147,7 +99,7 @@ class SubmitFormViewView(APIView): @transaction.atomic def post(self, request, slug): handler = ViewHandler() - form = handler.get_public_form_view_by_slug(request.user, slug) + form = handler.get_public_view_by_slug(request.user, slug, view_model=FormView) model = form.table.get_model() options = form.active_field_options @@ -215,7 +167,7 @@ class FormViewLinkRowFieldLookupView(APIView): ) def get(self, request, slug, field_id): handler = ViewHandler() - form = handler.get_public_form_view_by_slug(request.user, slug) + form = handler.get_public_view_by_slug(request.user, slug, view_model=FormView) link_row_field_content_type = ContentType.objects.get_for_model(LinkRowField) try: diff --git a/backend/src/baserow/contrib/database/api/views/serializers.py b/backend/src/baserow/contrib/database/api/views/serializers.py index 9b4e864b9..e30d71f12 100644 --- a/backend/src/baserow/contrib/database/api/views/serializers.py +++ b/backend/src/baserow/contrib/database/api/views/serializers.py @@ -171,7 +171,10 @@ class ViewSerializer(serializers.ModelSerializer): "sortings", "filters_disabled", ) - extra_kwargs = {"id": {"read_only": True}, "table_id": {"read_only": True}} + extra_kwargs = { + "id": {"read_only": True}, + "table_id": {"read_only": True}, + } def __init__(self, *args, **kwargs): context = kwargs.setdefault("context", {}) diff --git a/backend/src/baserow/contrib/database/api/views/urls.py b/backend/src/baserow/contrib/database/api/views/urls.py index 9a195c11d..d8cf1f36c 100644 --- a/backend/src/baserow/contrib/database/api/views/urls.py +++ b/backend/src/baserow/contrib/database/api/views/urls.py @@ -11,6 +11,7 @@ from .views import ( ViewSortingsView, ViewSortView, ViewFieldOptionsView, + RotateViewSlugView, ) @@ -43,4 +44,9 @@ urlpatterns = view_type_registry.api_urls + [ ViewFieldOptionsView.as_view(), name="field_options", ), + re_path( + r"(?P<view_id>[0-9]+)/rotate-slug/$", + RotateViewSlugView.as_view(), + name="rotate_slug", + ), ] diff --git a/backend/src/baserow/contrib/database/api/views/views.py b/backend/src/baserow/contrib/database/api/views/views.py index ebd74e8ea..784274464 100644 --- a/backend/src/baserow/contrib/database/api/views/views.py +++ b/backend/src/baserow/contrib/database/api/views/views.py @@ -46,6 +46,7 @@ from baserow.contrib.database.views.exceptions import ( ViewSortFieldNotSupported, UnrelatedFieldError, ViewDoesNotSupportFieldOptions, + CannotShareViewTypeError, ) from .serializers import ( @@ -72,6 +73,7 @@ from .errors import ( ERROR_VIEW_SORT_FIELD_NOT_SUPPORTED, ERROR_UNRELATED_FIELD, ERROR_VIEW_DOES_NOT_SUPPORT_FIELD_OPTIONS, + ERROR_CANNOT_SHARE_VIEW_TYPE, ) @@ -1040,3 +1042,55 @@ class ViewFieldOptionsView(APIView): serializer = serializer_class(view) return Response(serializer.data) + + +class RotateViewSlugView(APIView): + permission_classes = (IsAuthenticated,) + + @extend_schema( + parameters=[ + OpenApiParameter( + name="view_id", + location=OpenApiParameter.PATH, + type=OpenApiTypes.INT, + required=True, + description="Rotates the slug of the view related to the provided " + "value.", + ) + ], + tags=["Database table views"], + operation_id="rotate_database_view_slug", + description=( + "Rotates the unique slug of the view by replacing it with a new " + "value. This would mean that the publicly shared URL of the view will " + "change. Anyone with the old URL won't be able to access the view" + "anymore. Only view types which are sharable can have their slugs rotated." + ), + request=None, + responses={ + 200: DiscriminatorCustomFieldsMappingSerializer( + view_type_registry, + ViewSerializer, + ), + 400: get_error_schema( + ["ERROR_USER_NOT_IN_GROUP", "ERROR_CANNOT_SHARE_VIEW_TYPE"] + ), + 404: get_error_schema(["ERROR_VIEW_DOES_NOT_EXIST"]), + }, + ) + @map_exceptions( + { + UserNotInGroup: ERROR_USER_NOT_IN_GROUP, + ViewDoesNotExist: ERROR_VIEW_DOES_NOT_EXIST, + CannotShareViewTypeError: ERROR_CANNOT_SHARE_VIEW_TYPE, + } + ) + @transaction.atomic + def post(self, request, view_id): + """Rotates the slug of a view.""" + + handler = ViewHandler() + view = ViewHandler().get_view(view_id) + view = handler.rotate_view_slug(request.user, view) + serializer = view_type_registry.get_serializer(view, ViewSerializer) + return Response(serializer.data) diff --git a/backend/src/baserow/contrib/database/migrations/0053_add_and_move_public_flags.py b/backend/src/baserow/contrib/database/migrations/0053_add_and_move_public_flags.py new file mode 100644 index 000000000..620a4f04a --- /dev/null +++ b/backend/src/baserow/contrib/database/migrations/0053_add_and_move_public_flags.py @@ -0,0 +1,141 @@ +# Generated by Django 3.2.6 on 2021-12-14 09:16 +import math +import secrets + +from django.db import migrations, models +from tqdm import tqdm + + +def update_batch_size(): + # Exists as a function purely so tests can mock the return_value + return 1000 + + +def reverse(apps, schema_editor): + FormView = apps.get_model("database", "FormView") + + print("Migrating view public and slug properties back to form view...") + for f in FormView.objects.all(): + view = f.view_ptr + f.public = view.public_temp + f.slug = view.slug_temp + f.save() + + +# noinspection PyPep8Naming +def forward(apps, schema_editor): + FormView = apps.get_model("database", "FormView") + View = apps.get_model("database", "View") + + _copy_slug_and_public_from_form_to_view(FormView, View) + _generate_slugs_for_views(View) + + +def _copy_slug_and_public_from_form_to_view(FormView, View): + print("Migrating form view public and slug properties to View...") + updated_form_views = [] + for f in FormView.objects.all(): + view = f.view_ptr + view.public_temp = f.public + view.slug_temp = f.slug + updated_form_views.append(view) + View.objects.bulk_update(updated_form_views, fields=["public_temp", "slug_temp"]) + print("Done with form view") + + +def _generate_slugs_for_views(View): + views_to_generate_slugs_for = View.objects.filter(slug_temp__isnull=True) + view_count = views_to_generate_slugs_for.count() + batch_size = update_batch_size() + updated_views = [] + + # Use tqdm in manual mode as it doesn't work nicely wrapping generators like + # .iterator() + with tqdm( + total=math.ceil(view_count / batch_size), + desc=f"Generating slugs for {view_count} views in batches of {batch_size}", + ) as pbar: + for view in views_to_generate_slugs_for.iterator(): + view.slug_temp = secrets.token_urlsafe() + updated_views.append(view) + if len(updated_views) >= batch_size: + View.objects.bulk_update(updated_views, fields=["slug_temp"]) + updated_views.clear() + pbar.update(1) + View.objects.bulk_update(updated_views, fields=["slug_temp"]) + pbar.update(1) + + +class Migration(migrations.Migration): + + dependencies = [ + ("database", "0052_table_order_and_id_index"), + ] + + operations = [ + migrations.AddField( + model_name="view", + name="public_temp", + field=models.BooleanField( + default=False, + help_text="Indicates whether the view is publicly accessible to " + "visitors.", + ), + ), + migrations.AddField( + model_name="view", + name="slug_temp", + field=models.SlugField( + null=True, + blank=True, + help_text="The unique slug where the view can be accessed publicly on.", + unique=True, + db_index=True, + ), + ), + # Ensure that the slug is nullable on the formview so when migrating backwards + # so that the field is created filled with nulls which we then copy + # view.slug into. The reverse of this AlterField will set null=False, + # blank=False, default=secrets.token_urlsafe. + migrations.AlterField( + model_name="formview", + name="slug", + field=models.SlugField( + help_text="The unique slug where the form can be accessed " + "publicly on.", + null=True, + blank=True, + unique=True, + db_index=True, + ), + ), + migrations.RunPython(forward, reverse), + migrations.AlterField( + model_name="view", + name="slug_temp", + field=models.SlugField( + help_text="The unique slug where the view can be accessed publicly on.", + default=secrets.token_urlsafe, + unique=True, + db_index=True, + ), + ), + migrations.RemoveField( + model_name="formview", + name="public", + ), + migrations.RemoveField( + model_name="formview", + name="slug", + ), + migrations.RenameField( + model_name="view", + old_name="public_temp", + new_name="public", + ), + migrations.RenameField( + model_name="view", + old_name="slug_temp", + new_name="slug", + ), + ] diff --git a/backend/src/baserow/contrib/database/views/exceptions.py b/backend/src/baserow/contrib/database/views/exceptions.py index 815c13b9d..db8d0fbf5 100644 --- a/backend/src/baserow/contrib/database/views/exceptions.py +++ b/backend/src/baserow/contrib/database/views/exceptions.py @@ -8,6 +8,10 @@ class ViewDoesNotExist(Exception): """Raised when trying to get a view that doesn't exist.""" +class CannotShareViewTypeError(Exception): + """Raised when trying to a share a view that cannot be shared""" + + class ViewNotInTable(Exception): """Raised when a provided view does not belong to a table.""" diff --git a/backend/src/baserow/contrib/database/views/handler.py b/backend/src/baserow/contrib/database/views/handler.py index 49f64a9ef..06b4138a7 100644 --- a/backend/src/baserow/contrib/database/views/handler.py +++ b/backend/src/baserow/contrib/database/views/handler.py @@ -1,19 +1,19 @@ -from django.db.models import F from django.core.exceptions import FieldDoesNotExist, ValidationError +from django.db.models import F +from baserow.contrib.database.fields.exceptions import FieldNotInTable +from baserow.contrib.database.fields.field_filters import FilterBuilder +from baserow.contrib.database.fields.field_sortings import AnnotatedOrder +from baserow.contrib.database.fields.models import Field +from baserow.contrib.database.fields.registries import field_type_registry +from baserow.contrib.database.rows.handler import RowHandler +from baserow.contrib.database.rows.signals import row_created from baserow.core.trash.handler import TrashHandler from baserow.core.utils import ( extract_allowed, set_allowed_attrs, get_model_reference_field_name, ) -from baserow.contrib.database.fields.exceptions import FieldNotInTable -from baserow.contrib.database.fields.field_filters import FilterBuilder -from baserow.contrib.database.fields.models import Field -from baserow.contrib.database.fields.registries import field_type_registry -from baserow.contrib.database.fields.field_sortings import AnnotatedOrder -from baserow.contrib.database.rows.handler import RowHandler -from baserow.contrib.database.rows.signals import row_created from .exceptions import ( ViewDoesNotExist, ViewNotInTable, @@ -26,9 +26,9 @@ from .exceptions import ( ViewSortFieldAlreadyExist, ViewSortFieldNotSupported, ViewDoesNotSupportFieldOptions, + CannotShareViewTypeError, ) -from .validators import EMPTY_VALUES -from .models import View, ViewFilter, ViewSort, FormView +from .models import View, ViewFilter, ViewSort from .registries import view_type_registry, view_filter_type_registry from .signals import ( view_created, @@ -43,6 +43,7 @@ from .signals import ( view_sort_deleted, view_field_options_updated, ) +from .validators import EMPTY_VALUES class ViewHandler: @@ -782,55 +783,65 @@ class ViewHandler: queryset = queryset.search_all_fields(search) return queryset - def rotate_form_view_slug(self, user, form): + def rotate_view_slug(self, user, view): """ - Rotates the slug of the provided form view. + Rotates the slug of the provided view. - :param user: The user on whose behalf the form view is updated. + :param user: The user on whose behalf the view is updated. :type user: User - :param form: The form view instance that needs to be updated. - :type form: View + :param view: The form view instance that needs to be updated. + :type view: View :return: The updated view instance. :rtype: View + :raises CannotShareViewTypeError: Raised if called for a view which does not + support sharing. + """ + + view_type = view_type_registry.get_by_model(view.specific_class) + if not view_type.can_share: + raise CannotShareViewTypeError() + + group = view.table.database.group + group.has_user(user, raise_error=True) + + view.rotate_slug() + view.save() + + view_updated.send(self, view=view, user=user) + + return view + + def get_public_view_by_slug(self, user, slug, view_model=None): + """ + Returns the view with the provided slug if it is public or if the user has + access to the views group. + + :param user: The user on whose behalf the view is requested. + :type user: User + :param slug: The slug of the view. + :type slug: str + :param view_model: If provided that models objects are used to select the + view. This can for example be useful when you want to select a GridView or + other child of the View model. + :type view_model: Type[View] + :return: The requested view with matching slug. + :rtype: View """ - if not isinstance(form, FormView): - raise ValueError("The provided form is not an instance of FormView.") - - group = form.table.database.group - group.has_user(user, raise_error=True) - - form.rotate_slug() - form.save() - - view_updated.send(self, view=form, user=user) - - return form - - def get_public_form_view_by_slug(self, user, slug): - """ - Returns the form view related to the provided slug if the form related to the - slug is public or if the user has access to the related group. - - :param user: The user on whose behalf the form is requested. - :type user: User - :param slug: The slug of the form view. - :type slug: str - :return: The requested form view that belongs to the form with the slug. - :rtype: FormView - """ + if not view_model: + view_model = View try: - form = FormView.objects.get(slug=slug) - except (FormView.DoesNotExist, ValidationError): - raise ViewDoesNotExist("The form does not exist.") + view = view_model.objects.get(slug=slug) + except (view_model.DoesNotExist, ValidationError): + raise ViewDoesNotExist("The view does not exist.") - if not form.public and ( - not user or not form.table.database.group.has_user(user) + if not view.public and ( + not user or not view.table.database.group.has_user(user) ): - raise ViewDoesNotExist("The form does not exist.") + raise ViewDoesNotExist("The view does not exist.") - return form + return view def submit_form_view(self, form, values, model=None, enabled_field_options=None): """ diff --git a/backend/src/baserow/contrib/database/views/models.py b/backend/src/baserow/contrib/database/views/models.py index 7a6023f3f..b5b0156e3 100644 --- a/backend/src/baserow/contrib/database/views/models.py +++ b/backend/src/baserow/contrib/database/views/models.py @@ -70,6 +70,19 @@ class View( help_text="Allows users to see results unfiltered while still keeping " "the filters saved for the view.", ) + slug = models.SlugField( + default=secrets.token_urlsafe, + help_text="The unique slug where the view can be accessed publicly on.", + unique=True, + db_index=True, + ) + public = models.BooleanField( + default=False, + help_text="Indicates whether the view is publicly accessible to visitors.", + ) + + def rotate_slug(self): + self.slug = secrets.token_urlsafe() class Meta: ordering = ("order",) @@ -247,17 +260,6 @@ class GalleryViewFieldOptions(ParentFieldTrashableModelMixin, models.Model): class FormView(View): field_options = models.ManyToManyField(Field, through="FormViewFieldOptions") - slug = models.SlugField( - default=secrets.token_urlsafe, - help_text="The unique slug where the form can be accessed publicly on.", - unique=True, - db_index=True, - ) - public = models.BooleanField( - default=False, - help_text="Indicates whether the form is publicly accessible to visitors and " - "if they can fill it out.", - ) title = models.TextField( blank=True, help_text="The title that is displayed at the beginning of the form.", @@ -301,9 +303,6 @@ class FormView(View): f"form.", ) - def rotate_slug(self): - self.slug = secrets.token_urlsafe() - @property def active_field_options(self): return ( diff --git a/backend/src/baserow/contrib/database/views/registries.py b/backend/src/baserow/contrib/database/views/registries.py index 2b8d3fb08..ef41a41c8 100644 --- a/backend/src/baserow/contrib/database/views/registries.py +++ b/backend/src/baserow/contrib/database/views/registries.py @@ -1,6 +1,7 @@ from typing import Callable, Union, List from django.contrib.auth.models import User as DjangoUser +from rest_framework.fields import CharField from rest_framework.serializers import Serializer @@ -81,6 +82,11 @@ class ViewType( sort to the view. """ + can_share = False + """ + Indicates if the view supports being shared via a public link. + """ + field_options_model_class = None """ The model class of the through table that contains the field options. The model @@ -94,6 +100,23 @@ class ViewType( option changes. """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.can_share: + self.allowed_fields = self.allowed_fields + ["public"] + self.serializer_field_names = self.serializer_field_names + [ + "public", + "slug", + ] + self.serializer_field_overrides = { + **self.serializer_field_overrides, + "slug": CharField( + read_only=True, + help_text="The unique slug that can be used to construct a public " + "URL.", + ), + } + def export_serialized(self, view, files_zip, storage): """ Exports the view to a serialized dict that can be imported by the @@ -138,6 +161,9 @@ class ViewType( for sort in view.viewsort_set.all() ] + if self.can_share: + serialized["public"] = view.public + return serialized def import_serialized( diff --git a/backend/src/baserow/contrib/database/views/view_types.py b/backend/src/baserow/contrib/database/views/view_types.py index a76890a62..b96913763 100644 --- a/backend/src/baserow/contrib/database/views/view_types.py +++ b/backend/src/baserow/contrib/database/views/view_types.py @@ -1,23 +1,21 @@ from django.urls import path, include -from rest_framework.serializers import CharField - from baserow.api.user_files.serializers import UserFileField -from baserow.core.user_files.handler import UserFileHandler from baserow.contrib.database.api.views.form.errors import ( ERROR_FORM_VIEW_FIELD_TYPE_IS_NOT_SUPPORTED, ) -from baserow.contrib.database.fields.registries import field_type_registry -from baserow.contrib.database.api.views.grid.serializers import ( - GridViewFieldOptionsSerializer, -) -from baserow.contrib.database.api.views.gallery.serializers import ( - GalleryViewFieldOptionsSerializer, -) from baserow.contrib.database.api.views.form.serializers import ( FormViewFieldOptionsSerializer, ) - +from baserow.contrib.database.api.views.gallery.serializers import ( + GalleryViewFieldOptionsSerializer, +) +from baserow.contrib.database.api.views.grid.serializers import ( + GridViewFieldOptionsSerializer, +) +from baserow.contrib.database.fields.registries import field_type_registry +from baserow.core.user_files.handler import UserFileHandler +from .exceptions import FormViewFieldTypeIsNotSupported from .handler import ViewHandler from .models import ( GridView, @@ -28,7 +26,6 @@ from .models import ( FormViewFieldOptions, ) from .registries import ViewType -from .exceptions import FormViewFieldTypeIsNotSupported class GridViewType(ViewType): @@ -207,10 +204,10 @@ class FormViewType(ViewType): model_class = FormView can_filter = False can_sort = False + can_share = True field_options_model_class = FormViewFieldOptions field_options_serializer_class = FormViewFieldOptionsSerializer allowed_fields = [ - "public", "title", "description", "cover_image", @@ -220,8 +217,6 @@ class FormViewType(ViewType): "submit_action_redirect_url", ] serializer_field_names = [ - "slug", - "public", "title", "description", "cover_image", @@ -231,10 +226,6 @@ class FormViewType(ViewType): "submit_action_redirect_url", ] serializer_field_overrides = { - "slug": CharField( - read_only=True, - help_text="The unique slug that can be used to construct a public URL.", - ), "cover_image": UserFileField( required=False, help_text="The cover image that must be displayed at the top of the form.", @@ -291,7 +282,6 @@ class FormViewType(ViewType): return {"name": name, "original_name": user_file.original_name} - serialized["public"] = form.public serialized["title"] = form.title serialized["description"] = form.description serialized["cover_image"] = add_user_file(form.cover_image) diff --git a/backend/tests/baserow/contrib/database/api/views/form/test_form_view_views.py b/backend/tests/baserow/contrib/database/api/views/form/test_form_view_views.py index 583aa2ea4..552e051c1 100644 --- a/backend/tests/baserow/contrib/database/api/views/form/test_form_view_views.py +++ b/backend/tests/baserow/contrib/database/api/views/form/test_form_view_views.py @@ -192,35 +192,6 @@ def test_update_form_view(api_client, data_fixture): assert response_json["logo_image"]["name"] == user_file_2.name -@pytest.mark.django_db -def test_rotate_slug(api_client, data_fixture): - user, token = data_fixture.create_user_and_token() - table = data_fixture.create_database_table(user=user) - view = data_fixture.create_form_view(table=table) - view_2 = data_fixture.create_form_view() - old_slug = str(view.slug) - - url = reverse("api:database:views:form:rotate_slug", kwargs={"view_id": view_2.id}) - response = api_client.post(url, format="json", HTTP_AUTHORIZATION=f"JWT {token}") - assert response.status_code == HTTP_400_BAD_REQUEST - assert response.json()["error"] == "ERROR_USER_NOT_IN_GROUP" - - url = reverse("api:database:views:form:rotate_slug", kwargs={"view_id": 99999}) - response = api_client.post(url, format="json", HTTP_AUTHORIZATION=f"JWT {token}") - assert response.status_code == HTTP_404_NOT_FOUND - - url = reverse("api:database:views:form:rotate_slug", kwargs={"view_id": view.id}) - response = api_client.post( - url, - format="json", - HTTP_AUTHORIZATION=f"JWT {token}", - ) - response_json = response.json() - assert response.status_code == HTTP_200_OK - assert response_json["slug"] != old_slug - assert len(response_json["slug"]) == 43 - - @pytest.mark.django_db def test_meta_submit_form_view(api_client, data_fixture): user, token = data_fixture.create_user_and_token() diff --git a/backend/tests/baserow/contrib/database/api/views/grid/test_grid_view_views.py b/backend/tests/baserow/contrib/database/api/views/grid/test_grid_view_views.py index 536f8e1a7..7c1a104ab 100644 --- a/backend/tests/baserow/contrib/database/api/views/grid/test_grid_view_views.py +++ b/backend/tests/baserow/contrib/database/api/views/grid/test_grid_view_views.py @@ -646,6 +646,18 @@ def test_create_grid_view(api_client, data_fixture): assert "filters" not in response_json assert "sortings" not in response_json + # Can't create a public non sharable view. + response = api_client.post( + reverse("api:database:views:list", kwargs={"table_id": table.id}), + {"name": "Test 1", "type": "grid", "public": True}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + response_json = response.json() + assert response.status_code == HTTP_200_OK + assert "public" not in response_json + assert "slug" not in response_json + @pytest.mark.django_db def test_update_grid_view(api_client, data_fixture): @@ -733,3 +745,15 @@ def test_update_grid_view(api_client, data_fixture): assert response_json["filters_disabled"] is True assert response_json["filters"][0]["id"] == filter_1.id assert response_json["sortings"] == [] + + # Can't make a non sharable view public. + response = api_client.patch( + reverse("api:database:views:item", kwargs={"view_id": view.id}), + {"public": True}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + response_json = response.json() + assert response.status_code == HTTP_200_OK + assert "public" not in response_json + assert "slug" not in response_json diff --git a/backend/tests/baserow/contrib/database/api/views/test_view_views.py b/backend/tests/baserow/contrib/database/api/views/test_view_views.py index 2e6e5b0db..a4536f1f4 100644 --- a/backend/tests/baserow/contrib/database/api/views/test_view_views.py +++ b/backend/tests/baserow/contrib/database/api/views/test_view_views.py @@ -1313,3 +1313,38 @@ def test_patch_view_field_options(api_client, data_fixture): response_json = response.json() assert response.status_code == HTTP_400_BAD_REQUEST assert response_json["error"] == "ERROR_VIEW_DOES_NOT_SUPPORT_FIELD_OPTIONS" + + +@pytest.mark.django_db +def test_rotate_slug(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + table = data_fixture.create_database_table(user=user) + view = data_fixture.create_form_view(table=table) + view_2 = data_fixture.create_form_view(public=True) + grid_view = data_fixture.create_grid_view(user=user, table=table) + old_slug = str(view.slug) + + url = reverse("api:database:views:rotate_slug", kwargs={"view_id": view_2.id}) + response = api_client.post(url, format="json", HTTP_AUTHORIZATION=f"JWT {token}") + assert response.status_code == HTTP_400_BAD_REQUEST + assert response.json()["error"] == "ERROR_USER_NOT_IN_GROUP" + + url = reverse("api:database:views:rotate_slug", kwargs={"view_id": grid_view.id}) + response = api_client.post(url, format="json", HTTP_AUTHORIZATION=f"JWT {token}") + assert response.status_code == HTTP_400_BAD_REQUEST + assert response.json()["error"] == "ERROR_CANNOT_SHARE_VIEW_TYPE" + + url = reverse("api:database:views:rotate_slug", kwargs={"view_id": 99999}) + response = api_client.post(url, format="json", HTTP_AUTHORIZATION=f"JWT {token}") + assert response.status_code == HTTP_404_NOT_FOUND + + url = reverse("api:database:views:rotate_slug", kwargs={"view_id": view.id}) + response = api_client.post( + url, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + response_json = response.json() + assert response.status_code == HTTP_200_OK + assert response_json["slug"] != old_slug + assert len(response_json["slug"]) == 43 diff --git a/backend/tests/baserow/contrib/database/migrations/test_add_and_move_public_flags.py b/backend/tests/baserow/contrib/database/migrations/test_add_and_move_public_flags.py new file mode 100644 index 000000000..950a90c7a --- /dev/null +++ b/backend/tests/baserow/contrib/database/migrations/test_add_and_move_public_flags.py @@ -0,0 +1,173 @@ +import secrets +from unittest.mock import patch + +import pytest +from django.core.management import call_command +from django.db import DEFAULT_DB_ALIAS + +# noinspection PyPep8Naming +from django.db import connection +from django.db.migrations.executor import MigrationExecutor + +# noinspection PyPep8Naming + + +@pytest.mark.django_db +def test_forwards_migration(data_fixture, transactional_db): + migrate_from = [("database", "0052_table_order_and_id_index")] + migrate_to = [("database", "0053_add_and_move_public_flags")] + + old_state = migrate(migrate_from) + + # The models used by the data_fixture below are not touched by this migration so + # it is safe to use the latest version in the test. + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + FormView = old_state.apps.get_model("database", "FormView") + GridView = old_state.apps.get_model("database", "GridView") + ContentType = old_state.apps.get_model("contenttypes", "ContentType") + form_content_type_id = ContentType.objects.get_for_model(FormView).id + grid_content_type_id = ContentType.objects.get_for_model(GridView).id + form_view = FormView.objects.create( + table_id=table.id, + name="a", + order=1, + public=True, + slug="slug", + content_type_id=form_content_type_id, + ) + form_view2 = FormView.objects.create( + table_id=table.id, + name="b", + order=1, + public=False, + slug="slug2", + content_type_id=form_content_type_id, + ) + grid_view = GridView.objects.create( + table_id=table.id, + name="c", + order=1, + content_type_id=grid_content_type_id, + ) + grid_view2 = GridView.objects.create( + table_id=table.id, + name="d", + order=2, + content_type_id=grid_content_type_id, + ) + new_state = migrate(migrate_to) + NewFormView = new_state.apps.get_model("database", "FormView") + NewGridView = new_state.apps.get_model("database", "GridView") + + new_form_view = NewFormView.objects.get(id=form_view.id) + assert new_form_view.view_ptr.public + assert new_form_view.view_ptr.slug == form_view.slug + + new_form_view2 = NewFormView.objects.get(id=form_view2.id) + assert not new_form_view2.view_ptr.public + assert new_form_view2.view_ptr.slug == form_view2.slug + + new_grid_view = NewGridView.objects.get(id=grid_view.id) + assert not new_grid_view.view_ptr.public + assert new_grid_view.view_ptr.slug is not None + assert len(new_grid_view.view_ptr.slug) == len(secrets.token_urlsafe()) + + new_grid_view2 = NewGridView.objects.get(id=grid_view2.id) + assert not new_grid_view2.view_ptr.public + assert new_grid_view2.view_ptr.slug is not None + assert len(new_grid_view2.view_ptr.slug) == len(secrets.token_urlsafe()) + assert new_grid_view.view_ptr.slug != new_grid_view2.view_ptr.slug + + # We need to apply the latest migration otherwise other tests might fail. + call_command("migrate", verbosity=0, database=DEFAULT_DB_ALIAS) + + +@pytest.mark.django_db +@patch( + "baserow.contrib.database.migrations.0053_add_and_move_public_flags" + ".update_batch_size" +) +def test_multi_batch_forwards_migration( + patched_update_size, data_fixture, transactional_db +): + migrate_from = [("database", "0052_table_order_and_id_index")] + migrate_to = [("database", "0053_add_and_move_public_flags")] + + old_state = migrate(migrate_from) + + # The models used by the data_fixture below are not touched by this migration so + # it is safe to use the latest version in the test. + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + GridView = old_state.apps.get_model("database", "GridView") + ContentType = old_state.apps.get_model("contenttypes", "ContentType") + grid_content_type_id = ContentType.objects.get_for_model(GridView).id + size = 3 + patched_update_size.return_value = size + views_to_make = size * 2 + int(size / 2) + for i in range(views_to_make): + GridView.objects.create( + table_id=table.id, + name=str(i), + order=1, + content_type_id=grid_content_type_id, + ) + new_state = migrate(migrate_to) + NewGridView = new_state.apps.get_model("database", "GridView") + + assert not NewGridView.objects.filter(slug__isnull=True).exists() + assert not NewGridView.objects.filter(public=True).exists() + # We need to apply the latest migration otherwise other tests might fail. + call_command("migrate", verbosity=0, database=DEFAULT_DB_ALIAS) + + +@pytest.mark.django_db +def test_backwards_migration(data_fixture, transactional_db): + migrate_from = [("database", "0053_add_and_move_public_flags")] + migrate_to = [("database", "0052_table_order_and_id_index")] + + old_state = migrate(migrate_from) + + # The models used by the data_fixture below are not touched by this migration so + # it is safe to use the latest version in the test. + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + FormView = old_state.apps.get_model("database", "FormView") + ContentType = old_state.apps.get_model("contenttypes", "ContentType") + content_type_id = ContentType.objects.get_for_model(FormView).id + form_view = FormView.objects.create( + table_id=table.id, + name="a", + order=1, + public=True, + slug="slug", + content_type_id=content_type_id, + ) + form_view2 = FormView.objects.create( + table_id=table.id, + name="b", + order=2, + public=False, + slug="slug2", + content_type_id=content_type_id, + ) + new_state = migrate(migrate_to) + NewFormView = new_state.apps.get_model("database", "FormView") + new_form_view = NewFormView.objects.get(id=form_view.id) + new_form_view2 = NewFormView.objects.get(id=form_view2.id) + assert new_form_view.public + assert new_form_view.slug == form_view.slug + assert not new_form_view2.public + assert new_form_view2.slug == form_view2.slug + + # We need to apply the latest migration otherwise other tests might fail. + call_command("migrate", verbosity=0, database=DEFAULT_DB_ALIAS) + + +def migrate(target): + executor = MigrationExecutor(connection) + executor.loader.build_graph() # reload. + executor.migrate(target) + new_state = executor.loader.project_state(target) + return new_state diff --git a/backend/tests/baserow/contrib/database/view/test_view_handler.py b/backend/tests/baserow/contrib/database/view/test_view_handler.py index c8887fa4f..90c64d423 100644 --- a/backend/tests/baserow/contrib/database/view/test_view_handler.py +++ b/backend/tests/baserow/contrib/database/view/test_view_handler.py @@ -32,6 +32,7 @@ from baserow.contrib.database.views.exceptions import ( ViewSortFieldNotSupported, ViewDoesNotSupportFieldOptions, FormViewFieldTypeIsNotSupported, + CannotShareViewTypeError, ) from baserow.contrib.database.fields.models import Field from baserow.contrib.database.fields.handler import FieldHandler @@ -1263,22 +1264,23 @@ def test_delete_sort(send_mock, data_fixture): @pytest.mark.django_db @patch("baserow.contrib.database.views.signals.view_updated.send") -def test_rotate_form_view_slug(send_mock, data_fixture): +def test_rotate_view_slug(send_mock, data_fixture): user = data_fixture.create_user() user_2 = data_fixture.create_user() table = data_fixture.create_database_table(user=user) form = data_fixture.create_form_view(table=table) + grid = data_fixture.create_grid_view(table=table) old_slug = str(form.slug) handler = ViewHandler() with pytest.raises(UserNotInGroup): - handler.rotate_form_view_slug(user=user_2, form=form) + handler.rotate_view_slug(user=user_2, view=form) - with pytest.raises(ValueError): - handler.rotate_form_view_slug(user=user, form=object()) + with pytest.raises(CannotShareViewTypeError): + handler.rotate_view_slug(user=user, view=grid) - handler.rotate_form_view_slug(user=user, form=form) + handler.rotate_view_slug(user=user, view=form) send_mock.assert_called_once() assert send_mock.call_args[1]["view"].id == form.id @@ -1290,7 +1292,7 @@ def test_rotate_form_view_slug(send_mock, data_fixture): @pytest.mark.django_db -def test_get_public_form_view_by_slug(data_fixture): +def test_get_public_view_by_slug(data_fixture): user = data_fixture.create_user() user_2 = data_fixture.create_user() form = data_fixture.create_form_view(user=user) @@ -1298,25 +1300,27 @@ def test_get_public_form_view_by_slug(data_fixture): handler = ViewHandler() with pytest.raises(ViewDoesNotExist): - handler.get_public_form_view_by_slug(user_2, "not_existing") + handler.get_public_view_by_slug(user_2, "not_existing") with pytest.raises(ViewDoesNotExist): - handler.get_public_form_view_by_slug( - user_2, "a3f1493a-9229-4889-8531-6a65e745602e" - ) + handler.get_public_view_by_slug(user_2, "a3f1493a-9229-4889-8531-6a65e745602e") with pytest.raises(ViewDoesNotExist): - handler.get_public_form_view_by_slug(user_2, form.slug) + handler.get_public_view_by_slug(user_2, form.slug) - form2 = handler.get_public_form_view_by_slug(user, form.slug) + form2 = handler.get_public_view_by_slug(user, form.slug) assert form.id == form2.id form.public = True form.save() - form2 = handler.get_public_form_view_by_slug(user_2, form.slug) + form2 = handler.get_public_view_by_slug(user_2, form.slug) assert form.id == form2.id + form3 = handler.get_public_view_by_slug(user_2, form.slug, view_model=FormView) + assert form.id == form3.id + assert isinstance(form3, FormView) + @pytest.mark.django_db @patch("baserow.contrib.database.rows.signals.row_created.send") diff --git a/backend/tests/baserow/contrib/database/view/test_view_models.py b/backend/tests/baserow/contrib/database/view/test_view_models.py index a39cf0efc..e87c6ab48 100644 --- a/backend/tests/baserow/contrib/database/view/test_view_models.py +++ b/backend/tests/baserow/contrib/database/view/test_view_models.py @@ -54,7 +54,7 @@ def test_view_get_field_options(data_fixture): @pytest.mark.django_db -def test_rotate_form_view_slug(data_fixture): +def test_rotate_view_slug(data_fixture): form_view = data_fixture.create_form_view() old_slug = str(form_view.slug) form_view.rotate_slug() diff --git a/changelog.md b/changelog.md index 3d800c1ee..20cf77d7e 100644 --- a/changelog.md +++ b/changelog.md @@ -10,6 +10,8 @@ * **dev.sh users** Fixed bug in dev.sh where UID/GID were not being set correctly, please rebuild any dev images you are using. * Replaced the table `order` index with an `order, id` index to improve performance. +* **breaking change** The API endpoint to rotate a form views slug has been moved to + `/database/views/${viewId}/rotate-slug/`. ## Released (2021-11-25) diff --git a/web-frontend/modules/database/components/view/form/FormViewRotateSlugModal.vue b/web-frontend/modules/database/components/view/form/FormViewRotateSlugModal.vue index bbd236525..394d2d0da 100644 --- a/web-frontend/modules/database/components/view/form/FormViewRotateSlugModal.vue +++ b/web-frontend/modules/database/components/view/form/FormViewRotateSlugModal.vue @@ -29,7 +29,7 @@ import modal from '@baserow/modules/core/mixins/modal' import error from '@baserow/modules/core/mixins/error' import formViewHelpers from '@baserow/modules/database/mixins/formViewHelpers' -import FormService from '@baserow/modules/database/services/view/form' +import ViewService from '@baserow/modules/database/services/view' export default { name: 'FormViewRotateSlugModal', @@ -51,7 +51,7 @@ export default { this.loading = true try { - const { data } = await FormService(this.$client).rotateSlug( + const { data } = await ViewService(this.$client).rotateSlug( this.view.id ) await this.$store.dispatch('view/forceUpdate', { diff --git a/web-frontend/modules/database/services/view.js b/web-frontend/modules/database/services/view.js index 2522e2d0a..1e9a8fc15 100644 --- a/web-frontend/modules/database/services/view.js +++ b/web-frontend/modules/database/services/view.js @@ -58,5 +58,8 @@ export default (client) => { updateFieldOptions({ viewId, values }) { return client.patch(`/database/views/${viewId}/field-options/`, values) }, + rotateSlug(viewId) { + return client.post(`/database/views/${viewId}/rotate-slug/`) + }, } } diff --git a/web-frontend/modules/database/services/view/form.js b/web-frontend/modules/database/services/view/form.js index c3f5ba890..03864da5c 100644 --- a/web-frontend/modules/database/services/view/form.js +++ b/web-frontend/modules/database/services/view/form.js @@ -1,8 +1,5 @@ export default (client) => { return { - rotateSlug(formId) { - return client.post(`/database/views/form/${formId}/rotate-slug/`) - }, getMetaInformation(slug) { return client.get(`/database/views/form/${slug}/submit/`) },