mirror of
https://gitlab.com/bramw/baserow.git
synced 2025-04-17 02:17:49 +00:00
Merge branch '710-sharegridview-migrate-slug-public-from-form-view-to-all-views' into 'develop'
Resolve "📨 ShareGridView: Migrate slug+public from form view to all views" Closes #710 See merge request bramw/baserow!459
This commit is contained in:
commit
3e9fb65840
22 changed files with 585 additions and 192 deletions
backend
src/baserow/contrib/database
api/views
migrations
views
tests/baserow/contrib/database
web-frontend/modules/database
|
@ -61,3 +61,8 @@ ERROR_VIEW_DOES_NOT_SUPPORT_FIELD_OPTIONS = (
|
||||||
HTTP_400_BAD_REQUEST,
|
HTTP_400_BAD_REQUEST,
|
||||||
"This view model does not support field options.",
|
"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.",
|
||||||
|
)
|
||||||
|
|
|
@ -1,20 +1,13 @@
|
||||||
from django.urls import re_path
|
from django.urls import re_path
|
||||||
|
|
||||||
from .views import (
|
from .views import (
|
||||||
RotateFormViewSlugView,
|
|
||||||
SubmitFormViewView,
|
SubmitFormViewView,
|
||||||
FormViewLinkRowFieldLookupView,
|
FormViewLinkRowFieldLookupView,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
app_name = "baserow.contrib.database.api.views.form"
|
app_name = "baserow.contrib.database.api.views.form"
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
re_path(
|
|
||||||
r"(?P<view_id>[0-9]+)/rotate-slug/$",
|
|
||||||
RotateFormViewSlugView.as_view(),
|
|
||||||
name="rotate_slug",
|
|
||||||
),
|
|
||||||
re_path(
|
re_path(
|
||||||
r"(?P<slug>[-\w]+)/submit/$",
|
r"(?P<slug>[-\w]+)/submit/$",
|
||||||
SubmitFormViewView.as_view(),
|
SubmitFormViewView.as_view(),
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
from drf_spectacular.openapi import OpenApiParameter, OpenApiTypes
|
from drf_spectacular.openapi import OpenApiParameter, OpenApiTypes
|
||||||
from drf_spectacular.utils import extend_schema
|
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.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
from rest_framework.fields import empty
|
from rest_framework.fields import empty
|
||||||
|
@ -9,12 +9,10 @@ from django.contrib.contenttypes.models import ContentType
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
from baserow.api.decorators import map_exceptions
|
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.schemas import get_error_schema
|
||||||
from baserow.api.utils import validate_data
|
from baserow.api.utils import validate_data
|
||||||
from baserow.api.pagination import PageNumberPagination
|
from baserow.api.pagination import PageNumberPagination
|
||||||
from baserow.api.serializers import get_example_pagination_serializer_class
|
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 (
|
from baserow.contrib.database.api.rows.serializers import (
|
||||||
get_row_serializer_class,
|
get_row_serializer_class,
|
||||||
get_example_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.fields.exceptions import FieldDoesNotExist
|
||||||
from baserow.contrib.database.views.exceptions import ViewDoesNotExist
|
from baserow.contrib.database.views.exceptions import ViewDoesNotExist
|
||||||
from baserow.contrib.database.views.handler import ViewHandler
|
from baserow.contrib.database.views.handler import ViewHandler
|
||||||
from baserow.contrib.database.views.models import FormView, FormViewFieldOptions
|
from baserow.contrib.database.views.models import FormViewFieldOptions, FormView
|
||||||
from baserow.contrib.database.views.registries import view_type_registry
|
|
||||||
from baserow.contrib.database.views.validators import required_validator
|
from baserow.contrib.database.views.validators import required_validator
|
||||||
from baserow.core.exceptions import UserNotInGroup
|
|
||||||
|
|
||||||
from .errors import ERROR_FORM_DOES_NOT_EXIST
|
from .errors import ERROR_FORM_DOES_NOT_EXIST
|
||||||
from .serializers import PublicFormViewSerializer, FormViewSubmittedSerializer
|
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):
|
class SubmitFormViewView(APIView):
|
||||||
permission_classes = (AllowAny,)
|
permission_classes = (AllowAny,)
|
||||||
|
@ -111,7 +61,9 @@ class SubmitFormViewView(APIView):
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
def get(self, request, slug):
|
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)
|
serializer = PublicFormViewSerializer(form)
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@ -147,7 +99,7 @@ class SubmitFormViewView(APIView):
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def post(self, request, slug):
|
def post(self, request, slug):
|
||||||
handler = ViewHandler()
|
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()
|
model = form.table.get_model()
|
||||||
|
|
||||||
options = form.active_field_options
|
options = form.active_field_options
|
||||||
|
@ -215,7 +167,7 @@ class FormViewLinkRowFieldLookupView(APIView):
|
||||||
)
|
)
|
||||||
def get(self, request, slug, field_id):
|
def get(self, request, slug, field_id):
|
||||||
handler = ViewHandler()
|
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)
|
link_row_field_content_type = ContentType.objects.get_for_model(LinkRowField)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
|
@ -171,7 +171,10 @@ class ViewSerializer(serializers.ModelSerializer):
|
||||||
"sortings",
|
"sortings",
|
||||||
"filters_disabled",
|
"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):
|
def __init__(self, *args, **kwargs):
|
||||||
context = kwargs.setdefault("context", {})
|
context = kwargs.setdefault("context", {})
|
||||||
|
|
|
@ -11,6 +11,7 @@ from .views import (
|
||||||
ViewSortingsView,
|
ViewSortingsView,
|
||||||
ViewSortView,
|
ViewSortView,
|
||||||
ViewFieldOptionsView,
|
ViewFieldOptionsView,
|
||||||
|
RotateViewSlugView,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -43,4 +44,9 @@ urlpatterns = view_type_registry.api_urls + [
|
||||||
ViewFieldOptionsView.as_view(),
|
ViewFieldOptionsView.as_view(),
|
||||||
name="field_options",
|
name="field_options",
|
||||||
),
|
),
|
||||||
|
re_path(
|
||||||
|
r"(?P<view_id>[0-9]+)/rotate-slug/$",
|
||||||
|
RotateViewSlugView.as_view(),
|
||||||
|
name="rotate_slug",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -46,6 +46,7 @@ from baserow.contrib.database.views.exceptions import (
|
||||||
ViewSortFieldNotSupported,
|
ViewSortFieldNotSupported,
|
||||||
UnrelatedFieldError,
|
UnrelatedFieldError,
|
||||||
ViewDoesNotSupportFieldOptions,
|
ViewDoesNotSupportFieldOptions,
|
||||||
|
CannotShareViewTypeError,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
|
@ -72,6 +73,7 @@ from .errors import (
|
||||||
ERROR_VIEW_SORT_FIELD_NOT_SUPPORTED,
|
ERROR_VIEW_SORT_FIELD_NOT_SUPPORTED,
|
||||||
ERROR_UNRELATED_FIELD,
|
ERROR_UNRELATED_FIELD,
|
||||||
ERROR_VIEW_DOES_NOT_SUPPORT_FIELD_OPTIONS,
|
ERROR_VIEW_DOES_NOT_SUPPORT_FIELD_OPTIONS,
|
||||||
|
ERROR_CANNOT_SHARE_VIEW_TYPE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -1040,3 +1042,55 @@ class ViewFieldOptionsView(APIView):
|
||||||
|
|
||||||
serializer = serializer_class(view)
|
serializer = serializer_class(view)
|
||||||
return Response(serializer.data)
|
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)
|
||||||
|
|
|
@ -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",
|
||||||
|
),
|
||||||
|
]
|
|
@ -8,6 +8,10 @@ class ViewDoesNotExist(Exception):
|
||||||
"""Raised when trying to get a view that doesn't exist."""
|
"""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):
|
class ViewNotInTable(Exception):
|
||||||
"""Raised when a provided view does not belong to a table."""
|
"""Raised when a provided view does not belong to a table."""
|
||||||
|
|
||||||
|
|
|
@ -1,19 +1,19 @@
|
||||||
from django.db.models import F
|
|
||||||
from django.core.exceptions import FieldDoesNotExist, ValidationError
|
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.trash.handler import TrashHandler
|
||||||
from baserow.core.utils import (
|
from baserow.core.utils import (
|
||||||
extract_allowed,
|
extract_allowed,
|
||||||
set_allowed_attrs,
|
set_allowed_attrs,
|
||||||
get_model_reference_field_name,
|
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 (
|
from .exceptions import (
|
||||||
ViewDoesNotExist,
|
ViewDoesNotExist,
|
||||||
ViewNotInTable,
|
ViewNotInTable,
|
||||||
|
@ -26,9 +26,9 @@ from .exceptions import (
|
||||||
ViewSortFieldAlreadyExist,
|
ViewSortFieldAlreadyExist,
|
||||||
ViewSortFieldNotSupported,
|
ViewSortFieldNotSupported,
|
||||||
ViewDoesNotSupportFieldOptions,
|
ViewDoesNotSupportFieldOptions,
|
||||||
|
CannotShareViewTypeError,
|
||||||
)
|
)
|
||||||
from .validators import EMPTY_VALUES
|
from .models import View, ViewFilter, ViewSort
|
||||||
from .models import View, ViewFilter, ViewSort, FormView
|
|
||||||
from .registries import view_type_registry, view_filter_type_registry
|
from .registries import view_type_registry, view_filter_type_registry
|
||||||
from .signals import (
|
from .signals import (
|
||||||
view_created,
|
view_created,
|
||||||
|
@ -43,6 +43,7 @@ from .signals import (
|
||||||
view_sort_deleted,
|
view_sort_deleted,
|
||||||
view_field_options_updated,
|
view_field_options_updated,
|
||||||
)
|
)
|
||||||
|
from .validators import EMPTY_VALUES
|
||||||
|
|
||||||
|
|
||||||
class ViewHandler:
|
class ViewHandler:
|
||||||
|
@ -782,55 +783,65 @@ class ViewHandler:
|
||||||
queryset = queryset.search_all_fields(search)
|
queryset = queryset.search_all_fields(search)
|
||||||
return queryset
|
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
|
:type user: User
|
||||||
:param form: The form view instance that needs to be updated.
|
:param view: The form view instance that needs to be updated.
|
||||||
:type form: View
|
:type view: View
|
||||||
:return: The updated view instance.
|
:return: The updated view instance.
|
||||||
:rtype: View
|
: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):
|
if not view_model:
|
||||||
raise ValueError("The provided form is not an instance of FormView.")
|
view_model = View
|
||||||
|
|
||||||
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
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
form = FormView.objects.get(slug=slug)
|
view = view_model.objects.get(slug=slug)
|
||||||
except (FormView.DoesNotExist, ValidationError):
|
except (view_model.DoesNotExist, ValidationError):
|
||||||
raise ViewDoesNotExist("The form does not exist.")
|
raise ViewDoesNotExist("The view does not exist.")
|
||||||
|
|
||||||
if not form.public and (
|
if not view.public and (
|
||||||
not user or not form.table.database.group.has_user(user)
|
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):
|
def submit_form_view(self, form, values, model=None, enabled_field_options=None):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -70,6 +70,19 @@ class View(
|
||||||
help_text="Allows users to see results unfiltered while still keeping "
|
help_text="Allows users to see results unfiltered while still keeping "
|
||||||
"the filters saved for the view.",
|
"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:
|
class Meta:
|
||||||
ordering = ("order",)
|
ordering = ("order",)
|
||||||
|
@ -247,17 +260,6 @@ class GalleryViewFieldOptions(ParentFieldTrashableModelMixin, models.Model):
|
||||||
|
|
||||||
class FormView(View):
|
class FormView(View):
|
||||||
field_options = models.ManyToManyField(Field, through="FormViewFieldOptions")
|
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(
|
title = models.TextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text="The title that is displayed at the beginning of the form.",
|
help_text="The title that is displayed at the beginning of the form.",
|
||||||
|
@ -301,9 +303,6 @@ class FormView(View):
|
||||||
f"form.",
|
f"form.",
|
||||||
)
|
)
|
||||||
|
|
||||||
def rotate_slug(self):
|
|
||||||
self.slug = secrets.token_urlsafe()
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def active_field_options(self):
|
def active_field_options(self):
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
from typing import Callable, Union, List
|
from typing import Callable, Union, List
|
||||||
|
|
||||||
from django.contrib.auth.models import User as DjangoUser
|
from django.contrib.auth.models import User as DjangoUser
|
||||||
|
from rest_framework.fields import CharField
|
||||||
|
|
||||||
from rest_framework.serializers import Serializer
|
from rest_framework.serializers import Serializer
|
||||||
|
|
||||||
|
@ -81,6 +82,11 @@ class ViewType(
|
||||||
sort to the view.
|
sort to the view.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
can_share = False
|
||||||
|
"""
|
||||||
|
Indicates if the view supports being shared via a public link.
|
||||||
|
"""
|
||||||
|
|
||||||
field_options_model_class = None
|
field_options_model_class = None
|
||||||
"""
|
"""
|
||||||
The model class of the through table that contains the field options. The model
|
The model class of the through table that contains the field options. The model
|
||||||
|
@ -94,6 +100,23 @@ class ViewType(
|
||||||
option changes.
|
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):
|
def export_serialized(self, view, files_zip, storage):
|
||||||
"""
|
"""
|
||||||
Exports the view to a serialized dict that can be imported by the
|
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()
|
for sort in view.viewsort_set.all()
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if self.can_share:
|
||||||
|
serialized["public"] = view.public
|
||||||
|
|
||||||
return serialized
|
return serialized
|
||||||
|
|
||||||
def import_serialized(
|
def import_serialized(
|
||||||
|
|
|
@ -1,23 +1,21 @@
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
|
|
||||||
from rest_framework.serializers import CharField
|
|
||||||
|
|
||||||
from baserow.api.user_files.serializers import UserFileField
|
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 (
|
from baserow.contrib.database.api.views.form.errors import (
|
||||||
ERROR_FORM_VIEW_FIELD_TYPE_IS_NOT_SUPPORTED,
|
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 (
|
from baserow.contrib.database.api.views.form.serializers import (
|
||||||
FormViewFieldOptionsSerializer,
|
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 .handler import ViewHandler
|
||||||
from .models import (
|
from .models import (
|
||||||
GridView,
|
GridView,
|
||||||
|
@ -28,7 +26,6 @@ from .models import (
|
||||||
FormViewFieldOptions,
|
FormViewFieldOptions,
|
||||||
)
|
)
|
||||||
from .registries import ViewType
|
from .registries import ViewType
|
||||||
from .exceptions import FormViewFieldTypeIsNotSupported
|
|
||||||
|
|
||||||
|
|
||||||
class GridViewType(ViewType):
|
class GridViewType(ViewType):
|
||||||
|
@ -207,10 +204,10 @@ class FormViewType(ViewType):
|
||||||
model_class = FormView
|
model_class = FormView
|
||||||
can_filter = False
|
can_filter = False
|
||||||
can_sort = False
|
can_sort = False
|
||||||
|
can_share = True
|
||||||
field_options_model_class = FormViewFieldOptions
|
field_options_model_class = FormViewFieldOptions
|
||||||
field_options_serializer_class = FormViewFieldOptionsSerializer
|
field_options_serializer_class = FormViewFieldOptionsSerializer
|
||||||
allowed_fields = [
|
allowed_fields = [
|
||||||
"public",
|
|
||||||
"title",
|
"title",
|
||||||
"description",
|
"description",
|
||||||
"cover_image",
|
"cover_image",
|
||||||
|
@ -220,8 +217,6 @@ class FormViewType(ViewType):
|
||||||
"submit_action_redirect_url",
|
"submit_action_redirect_url",
|
||||||
]
|
]
|
||||||
serializer_field_names = [
|
serializer_field_names = [
|
||||||
"slug",
|
|
||||||
"public",
|
|
||||||
"title",
|
"title",
|
||||||
"description",
|
"description",
|
||||||
"cover_image",
|
"cover_image",
|
||||||
|
@ -231,10 +226,6 @@ class FormViewType(ViewType):
|
||||||
"submit_action_redirect_url",
|
"submit_action_redirect_url",
|
||||||
]
|
]
|
||||||
serializer_field_overrides = {
|
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(
|
"cover_image": UserFileField(
|
||||||
required=False,
|
required=False,
|
||||||
help_text="The cover image that must be displayed at the top of the form.",
|
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}
|
return {"name": name, "original_name": user_file.original_name}
|
||||||
|
|
||||||
serialized["public"] = form.public
|
|
||||||
serialized["title"] = form.title
|
serialized["title"] = form.title
|
||||||
serialized["description"] = form.description
|
serialized["description"] = form.description
|
||||||
serialized["cover_image"] = add_user_file(form.cover_image)
|
serialized["cover_image"] = add_user_file(form.cover_image)
|
||||||
|
|
|
@ -192,35 +192,6 @@ def test_update_form_view(api_client, data_fixture):
|
||||||
assert response_json["logo_image"]["name"] == user_file_2.name
|
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
|
@pytest.mark.django_db
|
||||||
def test_meta_submit_form_view(api_client, data_fixture):
|
def test_meta_submit_form_view(api_client, data_fixture):
|
||||||
user, token = data_fixture.create_user_and_token()
|
user, token = data_fixture.create_user_and_token()
|
||||||
|
|
|
@ -646,6 +646,18 @@ def test_create_grid_view(api_client, data_fixture):
|
||||||
assert "filters" not in response_json
|
assert "filters" not in response_json
|
||||||
assert "sortings" 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
|
@pytest.mark.django_db
|
||||||
def test_update_grid_view(api_client, data_fixture):
|
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_disabled"] is True
|
||||||
assert response_json["filters"][0]["id"] == filter_1.id
|
assert response_json["filters"][0]["id"] == filter_1.id
|
||||||
assert response_json["sortings"] == []
|
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
|
||||||
|
|
|
@ -1313,3 +1313,38 @@ def test_patch_view_field_options(api_client, data_fixture):
|
||||||
response_json = response.json()
|
response_json = response.json()
|
||||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||||
assert response_json["error"] == "ERROR_VIEW_DOES_NOT_SUPPORT_FIELD_OPTIONS"
|
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
|
||||||
|
|
|
@ -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
|
|
@ -32,6 +32,7 @@ from baserow.contrib.database.views.exceptions import (
|
||||||
ViewSortFieldNotSupported,
|
ViewSortFieldNotSupported,
|
||||||
ViewDoesNotSupportFieldOptions,
|
ViewDoesNotSupportFieldOptions,
|
||||||
FormViewFieldTypeIsNotSupported,
|
FormViewFieldTypeIsNotSupported,
|
||||||
|
CannotShareViewTypeError,
|
||||||
)
|
)
|
||||||
from baserow.contrib.database.fields.models import Field
|
from baserow.contrib.database.fields.models import Field
|
||||||
from baserow.contrib.database.fields.handler import FieldHandler
|
from baserow.contrib.database.fields.handler import FieldHandler
|
||||||
|
@ -1263,22 +1264,23 @@ def test_delete_sort(send_mock, data_fixture):
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@patch("baserow.contrib.database.views.signals.view_updated.send")
|
@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 = data_fixture.create_user()
|
||||||
user_2 = data_fixture.create_user()
|
user_2 = data_fixture.create_user()
|
||||||
table = data_fixture.create_database_table(user=user)
|
table = data_fixture.create_database_table(user=user)
|
||||||
form = data_fixture.create_form_view(table=table)
|
form = data_fixture.create_form_view(table=table)
|
||||||
|
grid = data_fixture.create_grid_view(table=table)
|
||||||
old_slug = str(form.slug)
|
old_slug = str(form.slug)
|
||||||
|
|
||||||
handler = ViewHandler()
|
handler = ViewHandler()
|
||||||
|
|
||||||
with pytest.raises(UserNotInGroup):
|
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):
|
with pytest.raises(CannotShareViewTypeError):
|
||||||
handler.rotate_form_view_slug(user=user, form=object())
|
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()
|
send_mock.assert_called_once()
|
||||||
assert send_mock.call_args[1]["view"].id == form.id
|
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
|
@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 = data_fixture.create_user()
|
||||||
user_2 = data_fixture.create_user()
|
user_2 = data_fixture.create_user()
|
||||||
form = data_fixture.create_form_view(user=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()
|
handler = ViewHandler()
|
||||||
|
|
||||||
with pytest.raises(ViewDoesNotExist):
|
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):
|
with pytest.raises(ViewDoesNotExist):
|
||||||
handler.get_public_form_view_by_slug(
|
handler.get_public_view_by_slug(user_2, "a3f1493a-9229-4889-8531-6a65e745602e")
|
||||||
user_2, "a3f1493a-9229-4889-8531-6a65e745602e"
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(ViewDoesNotExist):
|
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
|
assert form.id == form2.id
|
||||||
|
|
||||||
form.public = True
|
form.public = True
|
||||||
form.save()
|
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
|
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
|
@pytest.mark.django_db
|
||||||
@patch("baserow.contrib.database.rows.signals.row_created.send")
|
@patch("baserow.contrib.database.rows.signals.row_created.send")
|
||||||
|
|
|
@ -54,7 +54,7 @@ def test_view_get_field_options(data_fixture):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@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()
|
form_view = data_fixture.create_form_view()
|
||||||
old_slug = str(form_view.slug)
|
old_slug = str(form_view.slug)
|
||||||
form_view.rotate_slug()
|
form_view.rotate_slug()
|
||||||
|
|
|
@ -10,6 +10,8 @@
|
||||||
* **dev.sh users** Fixed bug in dev.sh where UID/GID were not being set correctly,
|
* **dev.sh users** Fixed bug in dev.sh where UID/GID were not being set correctly,
|
||||||
please rebuild any dev images you are using.
|
please rebuild any dev images you are using.
|
||||||
* Replaced the table `order` index with an `order, id` index to improve performance.
|
* 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)
|
## Released (2021-11-25)
|
||||||
|
|
||||||
|
|
|
@ -29,7 +29,7 @@
|
||||||
import modal from '@baserow/modules/core/mixins/modal'
|
import modal from '@baserow/modules/core/mixins/modal'
|
||||||
import error from '@baserow/modules/core/mixins/error'
|
import error from '@baserow/modules/core/mixins/error'
|
||||||
import formViewHelpers from '@baserow/modules/database/mixins/formViewHelpers'
|
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 {
|
export default {
|
||||||
name: 'FormViewRotateSlugModal',
|
name: 'FormViewRotateSlugModal',
|
||||||
|
@ -51,7 +51,7 @@ export default {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data } = await FormService(this.$client).rotateSlug(
|
const { data } = await ViewService(this.$client).rotateSlug(
|
||||||
this.view.id
|
this.view.id
|
||||||
)
|
)
|
||||||
await this.$store.dispatch('view/forceUpdate', {
|
await this.$store.dispatch('view/forceUpdate', {
|
||||||
|
|
|
@ -58,5 +58,8 @@ export default (client) => {
|
||||||
updateFieldOptions({ viewId, values }) {
|
updateFieldOptions({ viewId, values }) {
|
||||||
return client.patch(`/database/views/${viewId}/field-options/`, values)
|
return client.patch(`/database/views/${viewId}/field-options/`, values)
|
||||||
},
|
},
|
||||||
|
rotateSlug(viewId) {
|
||||||
|
return client.post(`/database/views/${viewId}/rotate-slug/`)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,5 @@
|
||||||
export default (client) => {
|
export default (client) => {
|
||||||
return {
|
return {
|
||||||
rotateSlug(formId) {
|
|
||||||
return client.post(`/database/views/form/${formId}/rotate-slug/`)
|
|
||||||
},
|
|
||||||
getMetaInformation(slug) {
|
getMetaInformation(slug) {
|
||||||
return client.get(`/database/views/form/${slug}/submit/`)
|
return client.get(`/database/views/form/${slug}/submit/`)
|
||||||
},
|
},
|
||||||
|
|
Loading…
Add table
Reference in a new issue