diff --git a/backend/src/baserow/config/settings/base.py b/backend/src/baserow/config/settings/base.py index e253e8c68..24ee0d2ec 100644 --- a/backend/src/baserow/config/settings/base.py +++ b/backend/src/baserow/config/settings/base.py @@ -521,6 +521,7 @@ SPECTACULAR_SETTINGS = { {"name": "Database table view sortings"}, {"name": "Database table view decorations"}, {"name": "Database table view groupings"}, + {"name": "Database table view export"}, {"name": "Database table grid view"}, {"name": "Database table gallery view"}, {"name": "Database table form view"}, diff --git a/backend/src/baserow/contrib/database/api/constants.py b/backend/src/baserow/contrib/database/api/constants.py index 510b9a593..6d8ab84ba 100644 --- a/backend/src/baserow/contrib/database/api/constants.py +++ b/backend/src/baserow/contrib/database/api/constants.py @@ -39,6 +39,36 @@ ONLY_COUNT_API_PARAM = OpenApiParameter( ) +def get_filters_object_description(combine_filters=True, view_is_aggregating=False): + return ( + ( + "A JSON serialized string containing the filter tree to apply " + "for the aggregation. The filter tree is a nested structure " + "containing the filters that need to be applied. \n\n" + if view_is_aggregating + else "A JSON serialized string containing the filter tree to " + "apply to this view. The filter tree is a nested structure " + "containing the filters that need to be applied. \n\n" + ) + + "An example of a valid filter tree is the following:" + '`{"filter_type": "AND", "filters": [{"field": 1, "type": "equal", ' + '"value": "test"}]}`. The `field` value must be the ID of the ' + "field to filter on, or the name of the field if " + "`user_field_names` is true.\n\n" + f"The following filters are available: " + f'{", ".join(view_filter_type_registry.get_types())}.' + "\n\n**Please note that if this parameter is provided, all other " + "`filter__{field}__{filter}` will be ignored, " + "as well as the `filter_type` parameter.**" + + ( + "\n\n**Please note that by passing the filters parameter the " + "view filters saved for the view itself will be ignored.**" + if not combine_filters + else "" + ) + ) + + def make_adhoc_filter_api_params(combine_filters=True, view_is_aggregating=False): """ Generate OpenAPI parameters for adhoc filter API params. @@ -66,32 +96,8 @@ def make_adhoc_filter_api_params(combine_filters=True, view_is_aggregating=False location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, description=lazy( - lambda: ( - ( - "A JSON serialized string containing the filter tree to apply " - "for the aggregation. The filter tree is a nested structure " - "containing the filters that need to be applied. \n\n" - if view_is_aggregating - else "A JSON serialized string containing the filter tree to " - "apply to this view. The filter tree is a nested structure " - "containing the filters that need to be applied. \n\n" - ) - + "An example of a valid filter tree is the following:" - '`{"filter_type": "AND", "filters": [{"field": 1, "type": "equal", ' - '"value": "test"}]}`. The `field` value must be the ID of the ' - "field to filter on, or the name of the field if " - "`user_field_names` is true.\n\n" - f"The following filters are available: " - f'{", ".join(view_filter_type_registry.get_types())}.' - "\n\n**Please note that if this parameter is provided, all other " - "`filter__{field}__{filter}` will be ignored, " - "as well as the `filter_type` parameter.**" - + ( - "\n\n**Please note that by passing the filters parameter the " - "view filters saved for the view itself will be ignored.**" - if not combine_filters - else "" - ) + lambda: get_filters_object_description( + combine_filters, view_is_aggregating ), str, )(), diff --git a/backend/src/baserow/contrib/database/api/export/serializers.py b/backend/src/baserow/contrib/database/api/export/serializers.py index fd673c07e..2e455bda7 100644 --- a/backend/src/baserow/contrib/database/api/export/serializers.py +++ b/backend/src/baserow/contrib/database/api/export/serializers.py @@ -5,6 +5,8 @@ from drf_spectacular.utils import extend_schema_field from rest_framework import fields, serializers from baserow.api.serializers import FileURLSerializerMixin +from baserow.contrib.database.api.constants import get_filters_object_description +from baserow.contrib.database.api.views.serializers import PublicViewFiltersSerializer from baserow.contrib.database.export.handler import ExportHandler from baserow.contrib.database.export.models import ExportJob from baserow.contrib.database.export.registries import table_exporter_registry @@ -122,6 +124,28 @@ class BaseExporterOptionsSerializer(serializers.Serializer): default="utf-8", help_text="The character set to use when creating the export file.", ) + filters = PublicViewFiltersSerializer( + required=False, + allow_null=True, + help_text=lazy( + lambda: get_filters_object_description(True, False), + str, + )(), + ) + order_by = serializers.CharField( + required=False, + allow_null=True, + allow_blank=True, + help_text="Optionally the rows can be ordered by provided field ids separated " + "by comma. By default a field is ordered in ascending (A-Z) order, but by " + "prepending the field with a '-' it can be ordered descending (Z-A).", + ) + fields = serializers.ListField( + required=False, + allow_null=True, + child=serializers.IntegerField(), + help_text="List of field IDs that must be included in the export, in the desired order.", + ) class CsvExporterOptionsSerializer(BaseExporterOptionsSerializer): diff --git a/backend/src/baserow/contrib/database/api/export/views.py b/backend/src/baserow/contrib/database/api/export/views.py index e2c041253..44c9b9eae 100644 --- a/backend/src/baserow/contrib/database/api/export/views.py +++ b/backend/src/baserow/contrib/database/api/export/views.py @@ -22,9 +22,16 @@ from baserow.contrib.database.api.export.serializers import ( BaseExporterOptionsSerializer, ExportJobSerializer, ) +from baserow.contrib.database.api.fields.errors import ( + ERROR_FILTER_FIELD_NOT_FOUND, + ERROR_ORDER_BY_FIELD_NOT_FOUND, + ERROR_ORDER_BY_FIELD_NOT_POSSIBLE, +) from baserow.contrib.database.api.tables.errors import ERROR_TABLE_DOES_NOT_EXIST from baserow.contrib.database.api.views.errors import ( ERROR_VIEW_DOES_NOT_EXIST, + ERROR_VIEW_FILTER_TYPE_DOES_NOT_EXIST, + ERROR_VIEW_FILTER_TYPE_UNSUPPORTED_FIELD, ERROR_VIEW_NOT_IN_TABLE, ) from baserow.contrib.database.export.exceptions import ( @@ -34,9 +41,19 @@ from baserow.contrib.database.export.exceptions import ( from baserow.contrib.database.export.handler import ExportHandler from baserow.contrib.database.export.models import ExportJob from baserow.contrib.database.export.registries import table_exporter_registry +from baserow.contrib.database.fields.exceptions import ( + FilterFieldNotFound, + OrderByFieldNotFound, + OrderByFieldNotPossible, +) from baserow.contrib.database.table.exceptions import TableDoesNotExist from baserow.contrib.database.table.handler import TableHandler -from baserow.contrib.database.views.exceptions import ViewDoesNotExist, ViewNotInTable +from baserow.contrib.database.views.exceptions import ( + ViewDoesNotExist, + ViewFilterTypeDoesNotExist, + ViewFilterTypeNotAllowedForField, + ViewNotInTable, +) from baserow.contrib.database.views.handler import ViewHandler from baserow.core.exceptions import UserNotInWorkspace @@ -95,6 +112,11 @@ class ExportTableView(APIView): "ERROR_TABLE_ONLY_EXPORT_UNSUPPORTED", "ERROR_VIEW_UNSUPPORTED_FOR_EXPORT_TYPE", "ERROR_VIEW_NOT_IN_TABLE", + "ERROR_FILTER_FIELD_NOT_FOUND", + "ERROR_VIEW_FILTER_TYPE_DOES_NOT_EXIST", + "ERROR_VIEW_FILTER_TYPE_UNSUPPORTED_FIELD", + "ERROR_ORDER_BY_FIELD_NOT_FOUND", + "ERROR_ORDER_BY_FIELD_NOT_POSSIBLE", ] ), 404: get_error_schema( @@ -110,6 +132,11 @@ class ExportTableView(APIView): ViewDoesNotExist: ERROR_VIEW_DOES_NOT_EXIST, TableOnlyExportUnsupported: ERROR_TABLE_ONLY_EXPORT_UNSUPPORTED, ViewNotInTable: ERROR_VIEW_NOT_IN_TABLE, + FilterFieldNotFound: ERROR_FILTER_FIELD_NOT_FOUND, + ViewFilterTypeDoesNotExist: ERROR_VIEW_FILTER_TYPE_DOES_NOT_EXIST, + ViewFilterTypeNotAllowedForField: ERROR_VIEW_FILTER_TYPE_UNSUPPORTED_FIELD, + OrderByFieldNotFound: ERROR_ORDER_BY_FIELD_NOT_FOUND, + OrderByFieldNotPossible: ERROR_ORDER_BY_FIELD_NOT_POSSIBLE, } ) def post(self, request, table_id): diff --git a/backend/src/baserow/contrib/database/api/views/form/serializers.py b/backend/src/baserow/contrib/database/api/views/form/serializers.py index 0dcbf128b..6ed2d43ad 100644 --- a/backend/src/baserow/contrib/database/api/views/form/serializers.py +++ b/backend/src/baserow/contrib/database/api/views/form/serializers.py @@ -169,6 +169,7 @@ class PublicFormViewSerializer(serializers.ModelSerializer): "submit_text", "fields", "show_logo", + "allow_public_export", ) diff --git a/backend/src/baserow/contrib/database/api/views/serializers.py b/backend/src/baserow/contrib/database/api/views/serializers.py index 8ceaf19f3..34093b768 100644 --- a/backend/src/baserow/contrib/database/api/views/serializers.py +++ b/backend/src/baserow/contrib/database/api/views/serializers.py @@ -402,6 +402,7 @@ class ViewSerializer(serializers.ModelSerializer): "filters_disabled", "public_view_has_password", "show_logo", + "allow_public_export", "ownership_type", "owned_by_id", ) @@ -618,6 +619,7 @@ class PublicViewSerializer(serializers.ModelSerializer): "public", "slug", "show_logo", + "allow_public_export", ) extra_kwargs = { "id": {"read_only": True}, diff --git a/backend/src/baserow/contrib/database/export/file_writer.py b/backend/src/baserow/contrib/database/export/file_writer.py index 28f897b94..89adaef74 100644 --- a/backend/src/baserow/contrib/database/export/file_writer.py +++ b/backend/src/baserow/contrib/database/export/file_writer.py @@ -9,6 +9,7 @@ import unicodecsv as csv from baserow.contrib.database.export.exceptions import ExportJobCanceledException from baserow.contrib.database.table.models import FieldObject +from baserow.contrib.database.views.filters import AdHocFilters from baserow.contrib.database.views.handler import ViewHandler from baserow.contrib.database.views.registries import view_type_registry @@ -170,20 +171,47 @@ class QuerysetSerializer(abc.ABC): return cls(qs, ordered_field_objects) @classmethod - def for_view(cls, view) -> "QuerysetSerializer": + def for_view(cls, view, visible_field_ids_in_order=None) -> "QuerysetSerializer": """ Generates a queryset serializer for the provided view according to it's view type and any relevant view settings it might have (filters, sorts, hidden columns etc). :param view: The view to serialize. + :param visible_field_ids_in_order: Optionally provide a list of field IDs in + the correct order. Only those fields will be included in the export. :return: A QuerysetSerializer ready to serialize the table. """ view_type = view_type_registry.get_by_model(view.specific_class) - fields, model = view_type.get_visible_fields_and_model(view) + visible_field_objects_in_view, model = view_type.get_visible_fields_and_model( + view + ) + if visible_field_ids_in_order is None: + fields = visible_field_objects_in_view + else: + # Re-order and return only the fields in visible_field_ids_in_order + field_map = { + field_object["field"].id: field_object + for field_object in visible_field_objects_in_view + } + fields = [ + field_map[field_id] + for field_id in visible_field_ids_in_order + if field_id in field_map + ] qs = ViewHandler().get_queryset(view, model=model) - return cls(qs, fields) + return cls(qs, fields), visible_field_objects_in_view + + def add_ad_hoc_filters_dict_to_queryset(self, filters_dict, only_by_field_ids=None): + filters = AdHocFilters.from_dict(filters_dict) + filters.only_filter_by_field_ids = only_by_field_ids + self.queryset = filters.apply_to_queryset(self.queryset.model, self.queryset) + + def add_add_hoc_order_by_to_queryset(self, order_by, only_by_field_ids=None): + self.queryset = self.queryset.order_by_fields_string( + order_by, only_order_by_field_ids=only_by_field_ids + ) def _get_field_serializer(self, field_object: FieldObject) -> Callable[[Any], Any]: """ diff --git a/backend/src/baserow/contrib/database/export/handler.py b/backend/src/baserow/contrib/database/export/handler.py index 20b007c5f..2c9506560 100755 --- a/backend/src/baserow/contrib/database/export/handler.py +++ b/backend/src/baserow/contrib/database/export/handler.py @@ -22,6 +22,7 @@ from baserow.contrib.database.export.operations import ExportTableOperationType from baserow.contrib.database.export.tasks import run_export_job from baserow.contrib.database.table.models import Table from baserow.contrib.database.views.exceptions import ViewNotInTable +from baserow.contrib.database.views.filters import AdHocFilters from baserow.contrib.database.views.models import View from baserow.contrib.database.views.registries import view_type_registry from baserow.core.handler import CoreHandler @@ -37,14 +38,35 @@ from .exceptions import ( ) from .file_writer import PaginatedExportJobFileWriter from .registries import TableExporter, table_exporter_registry +from .utils import view_is_publicly_exportable User = get_user_model() class ExportHandler: + @staticmethod + def _raise_if_no_export_permissions( + user: Optional[User], table: Table, view: Optional[View] + ): + if view_is_publicly_exportable(user, view): + # No need to do the permission check if no user is provided, the view is + # public, and allowed to export from publicly shared view because this + # can be initiated by an anonymous user. + pass + else: + CoreHandler().check_permissions( + user, + ExportTableOperationType.type, + workspace=table.database.workspace, + context=table, + ) + @staticmethod def create_and_start_new_job( - user: User, table: Table, view: Optional[View], export_options: Dict[str, Any] + user: Optional[User], + table: Table, + view: Optional[View], + export_options: Dict[str, Any], ) -> ExportJob: """ For the provided user, table, optional view and options will create a new @@ -69,7 +91,10 @@ class ExportHandler: @staticmethod def create_pending_export_job( - user: User, table: Table, view: Optional[View], export_options: Dict[str, Any] + user: Optional[User], + table: Table, + view: Optional[View], + export_options: Dict[str, Any], ): """ Creates a new pending export job configured with the providing options but does @@ -92,12 +117,7 @@ class ExportHandler: exporter = table_exporter_registry.get(exporter_type) exporter.before_job_create(user, table, view, export_options) - CoreHandler().check_permissions( - user, - ExportTableOperationType.type, - workspace=table.database.workspace, - context=table, - ) + ExportHandler._raise_if_no_export_permissions(user, table, view) if view and view.table.id != table.id: raise ViewNotInTable() @@ -105,6 +125,7 @@ class ExportHandler: _cancel_unfinished_jobs(user) _raise_if_invalid_view_or_table_for_exporter(exporter_type, view) + _raise_if_invalid_order_by_or_filters(table, view, export_options) job = ExportJob.objects.create( user=user, @@ -132,12 +153,8 @@ class ExportHandler: # Ensure the user still has permissions when the export job runs. table = job.table - CoreHandler().check_permissions( - job.user, - ExportTableOperationType.type, - workspace=table.database.workspace, - context=table, - ) + view = job.view + ExportHandler._raise_if_no_export_permissions(job.user, table, view) try: return _mark_job_as_finished(_open_file_and_run_export(job)) except ExportJobCanceledException: @@ -206,6 +223,51 @@ def _raise_if_invalid_view_or_table_for_exporter( raise ViewUnsupportedForExporterType() +def _raise_if_invalid_order_by_or_filters( + table: Table, view: Optional[View], export_options: dict +): + """ + Validates that the filters and order_by specified in export_options only reference + fields that exist in the table and are visible in the view (if provided). + + This method attempts to apply the filters and ordering to a queryset to catch any + invalid field references before starting the actual export job. It raises an + exception if any validation fails. + + :param table: The table where to check the IDs in. + :param view: Optionally provide a view to check the visible fields off. + :param export_options: The export options where to extract the filters and order_by + from. + """ + + model = table.get_model() + queryset = model.objects.all() + + only_by_field_ids = None + if view: + view_type = view_type_registry.get_by_model(view.specific_class) + visible_field_objects_in_view, model = view_type.get_visible_fields_and_model( + view + ) + only_by_field_ids = [f["field"].id for f in visible_field_objects_in_view] + + # Validate the filter object before the job start, so that the validation error + # can be shown to the user. + filters_dict = export_options.get("filters", None) + if filters_dict is not None: + filters = AdHocFilters.from_dict(filters_dict) + filters.only_filter_by_field_ids = only_by_field_ids + filters.apply_to_queryset(model, queryset) + + # Validate the sort object before the job start, so that the validation error + # can be shown to the user. + order_by = export_options.get("order_by", None) + if order_by is not None: + queryset.order_by_fields_string( + order_by, only_order_by_field_ids=only_by_field_ids + ) + + def _cancel_unfinished_jobs(user): """ Will cancel any in progress jobs by setting their state to cancelled. Any @@ -216,8 +278,11 @@ def _cancel_unfinished_jobs(user): :return The number of jobs cancelled. """ - jobs = ExportJob.unfinished_jobs(user=user) - return jobs.update(state=EXPORT_JOB_CANCELLED_STATUS) + if user is None: + return 0 + else: + jobs = ExportJob.unfinished_jobs(user=user) + return jobs.update(state=EXPORT_JOB_CANCELLED_STATUS) def _mark_job_as_finished(export_job: ExportJob) -> ExportJob: @@ -287,12 +352,30 @@ def _open_file_and_run_export(job: ExportJob) -> ExportJob: # TODO: refactor to use the jobs systems _register_action(job) + filters = job.export_options.pop("filters", None) + order_by = job.export_options.pop("order_by", None) + visible_fields_in_order = job.export_options.pop("fields", None) + only_by_field_ids = None + with _create_storage_dir_if_missing_and_open(storage_location) as file: queryset_serializer_class = exporter.queryset_serializer_class if job.view is None: serializer = queryset_serializer_class.for_table(job.table) else: - serializer = queryset_serializer_class.for_view(job.view) + serializer, visible_fields_in_view = queryset_serializer_class.for_view( + job.view, visible_fields_in_order + ) + only_by_field_ids = [f["field"].id for f in visible_fields_in_view] + + if filters is not None: + serializer.add_ad_hoc_filters_dict_to_queryset( + filters, only_by_field_ids=only_by_field_ids + ) + + if order_by is not None: + serializer.add_add_hoc_order_by_to_queryset( + order_by, only_by_field_ids=only_by_field_ids + ) serializer.write_to_file( PaginatedExportJobFileWriter(file, job), **job.export_options diff --git a/backend/src/baserow/contrib/database/export/utils.py b/backend/src/baserow/contrib/database/export/utils.py new file mode 100644 index 000000000..80cc107c2 --- /dev/null +++ b/backend/src/baserow/contrib/database/export/utils.py @@ -0,0 +1,17 @@ +from typing import Optional + +from django.contrib.auth.models import AbstractUser + +from baserow.contrib.database.views.models import View + + +def view_is_publicly_exportable(user: Optional[AbstractUser], view: View): + """ + Checks if a view can be publicly exported for the given user. + + :param user: The (optional) user on whose behalf the check must be completed. + :param view: The view to check. + :return: Indicates whether the view is publicly exportable + """ + + return user is None and view and view.allow_public_export and view.public diff --git a/backend/src/baserow/contrib/database/migrations/0179_view_allow_public_export.py b/backend/src/baserow/contrib/database/migrations/0179_view_allow_public_export.py new file mode 100644 index 000000000..3b481a170 --- /dev/null +++ b/backend/src/baserow/contrib/database/migrations/0179_view_allow_public_export.py @@ -0,0 +1,21 @@ +# Generated by Django 5.0.9 on 2024-12-10 20:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("database", "0178_remove_singleselect_missing_options"), + ] + + operations = [ + migrations.AddField( + model_name="view", + name="allow_public_export", + field=models.BooleanField( + db_default=False, + default=False, + help_text="Indicates whether it's allowed to export a publicly shared view.", + ), + ), + ] diff --git a/backend/src/baserow/contrib/database/views/handler.py b/backend/src/baserow/contrib/database/views/handler.py index 70c831a4d..1cefac047 100644 --- a/backend/src/baserow/contrib/database/views/handler.py +++ b/backend/src/baserow/contrib/database/views/handler.py @@ -967,6 +967,7 @@ class ViewHandler(metaclass=baserow_trace_methods(tracer)): "filters_disabled", "public_view_password", "show_logo", + "allow_public_export", ] + view_type.allowed_fields changed_allowed_keys = set(extract_allowed(view_values, allowed_fields).keys()) diff --git a/backend/src/baserow/contrib/database/views/models.py b/backend/src/baserow/contrib/database/views/models.py index f711013da..ffd469ae9 100644 --- a/backend/src/baserow/contrib/database/views/models.py +++ b/backend/src/baserow/contrib/database/views/models.py @@ -112,6 +112,11 @@ class View( default=True, help_text="Indicates whether the logo should be shown in the public view.", ) + allow_public_export = models.BooleanField( + default=False, + db_default=False, + help_text="Indicates whether it's allowed to export a publicly shared view.", + ) owned_by = models.ForeignKey( User, null=True, diff --git a/backend/src/baserow/core/action/registries.py b/backend/src/baserow/core/action/registries.py index 71c280402..a6f4b5958 100755 --- a/backend/src/baserow/core/action/registries.py +++ b/backend/src/baserow/core/action/registries.py @@ -249,7 +249,7 @@ class ActionType( action_timestamp = timestamp if timestamp else datetime.now(tz=timezone.utc) add_baserow_trace_attrs( - action_user_id=user.id, + action_user_id=getattr(user, "id", None), workspace_id=getattr(workspace, "id", None), action_scope=scope, action_type=cls.type, diff --git a/backend/src/baserow/core/action/signals.py b/backend/src/baserow/core/action/signals.py index e4b07d65d..15c09a774 100755 --- a/backend/src/baserow/core/action/signals.py +++ b/backend/src/baserow/core/action/signals.py @@ -30,5 +30,5 @@ def log_action_receiver( action_command_type=action_command_type.name.lower(), workspace_id=workspace.id if workspace else "", action_type=action_type.type, - user_id=user.id, + user_id=getattr(user, "id", None), ) diff --git a/backend/src/baserow/core/posthog.py b/backend/src/baserow/core/posthog.py index 773de5c7a..42f7ea5c8 100644 --- a/backend/src/baserow/core/posthog.py +++ b/backend/src/baserow/core/posthog.py @@ -61,7 +61,7 @@ def capture_user_event( :param workspace: Optionally the workspace related to the event. """ - if user.is_anonymous: + if user is None or user.is_anonymous: # The user_id cannot be None. It's needed by Posthog to identify the user user_id = str(uuid4()) user_email = None diff --git a/backend/tests/baserow/contrib/database/api/views/gallery/test_gallery_view_views.py b/backend/tests/baserow/contrib/database/api/views/gallery/test_gallery_view_views.py index 061c87486..5438fc53e 100644 --- a/backend/tests/baserow/contrib/database/api/views/gallery/test_gallery_view_views.py +++ b/backend/tests/baserow/contrib/database/api/views/gallery/test_gallery_view_views.py @@ -840,6 +840,7 @@ def test_get_public_gallery_view(api_client, data_fixture): "type": "gallery", "card_cover_image_field": None, "show_logo": True, + "allow_public_export": False, }, } 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 b8b4744a3..dd9cda5c8 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 @@ -3310,6 +3310,7 @@ def test_get_public_grid_view(api_client, data_fixture): "row_identifier_type": grid_view.row_identifier_type, "row_height_size": grid_view.row_height_size, "show_logo": True, + "allow_public_export": False, }, } 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 09668ce55..6f9a864de 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 @@ -971,6 +971,7 @@ def test_user_with_password_can_get_info_about_a_public_password_protected_view( "row_identifier_type": grid_view.row_identifier_type, "row_height_size": grid_view.row_height_size, "show_logo": grid_view.show_logo, + "allow_public_export": grid_view.allow_public_export, }, } @@ -1000,6 +1001,7 @@ def test_user_with_password_can_get_info_about_a_public_password_protected_view( "row_identifier_type": grid_view.row_identifier_type, "row_height_size": grid_view.row_height_size, "show_logo": grid_view.show_logo, + "allow_public_export": grid_view.allow_public_export, }, } @@ -1137,6 +1139,29 @@ def test_view_cant_update_show_logo(data_fixture, api_client): assert response_data["show_logo"] is True +@pytest.mark.django_db +def test_view_cant_update_allow_public_export(data_fixture, api_client): + user, token = data_fixture.create_user_and_token() + table = data_fixture.create_database_table(user=user) + view = data_fixture.create_grid_view( + user=user, table=table, allow_public_export=False + ) + data = {"allow_public_export": True} + + response = api_client.patch( + reverse("api:database:views:item", kwargs={"view_id": view.id}), + data, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + + view.refresh_from_db() + assert view.allow_public_export is False + + response_data = response.json() + assert response_data["allow_public_export"] is False + + @pytest.mark.django_db(transaction=True) def test_loading_a_sortable_view_will_create_an_index( api_client, data_fixture, enable_singleton_testing diff --git a/backend/tests/baserow/contrib/database/field/test_field_single_select_options.py b/backend/tests/baserow/contrib/database/field/test_field_single_select_options.py index 57a94643b..5d2fca6bc 100644 --- a/backend/tests/baserow/contrib/database/field/test_field_single_select_options.py +++ b/backend/tests/baserow/contrib/database/field/test_field_single_select_options.py @@ -14,6 +14,10 @@ from baserow.contrib.database.rows.handler import RowHandler # @pytest.mark.disabled_in_ci # Disable this test in CI in next release. @pytest.mark.django_db @override_settings(BASEROW_DISABLE_MODEL_CACHE=True) +@pytest.mark.skip( + "Fails because it uses the latest version of the models instead of the ones at the " + "time of the migration" +) def test_migration_rows_with_deleted_singleselect_options( data_fixture, migrator, teardown_table_metadata ): diff --git a/backend/tests/baserow/contrib/database/import_export/test_export_handler.py b/backend/tests/baserow/contrib/database/import_export/test_export_handler.py index 5dee694ac..e3e9f082f 100755 --- a/backend/tests/baserow/contrib/database/import_export/test_export_handler.py +++ b/backend/tests/baserow/contrib/database/import_export/test_export_handler.py @@ -39,6 +39,7 @@ from baserow.contrib.database.fields.handler import FieldHandler from baserow.contrib.database.rows.handler import RowHandler from baserow.contrib.database.views.exceptions import ViewNotInTable from baserow.contrib.database.views.models import GridView, GridViewFieldOptions +from baserow.core.exceptions import PermissionDenied from baserow.test_utils.helpers import setup_interesting_test_table @@ -179,6 +180,65 @@ def test_exporting_table_ignores_view_filters_sorts_hides( assert contents == expected +@pytest.mark.django_db +@patch("baserow.core.storage.get_default_storage") +def test_exporting_public_view_without_user_fails_if_not_publicly_shared_and_allowed( + get_storage_mock, data_fixture +): + storage_mock = MagicMock() + get_storage_mock.return_value = storage_mock + table = data_fixture.create_database_table() + text_field = data_fixture.create_text_field(table=table, name="text_field", order=1) + grid_view = data_fixture.create_grid_view( + table=table, public=False, allow_public_export=False + ) + model = table.get_model() + model.objects.create( + **{ + f"field_{text_field.id}": "hello", + }, + ) + + with pytest.raises(PermissionDenied): + run_export_job_with_mock_storage(table, grid_view, storage_mock, None) + + grid_view.public = True + grid_view.allow_public_export = False + grid_view.save() + + with pytest.raises(PermissionDenied): + run_export_job_with_mock_storage(table, grid_view, storage_mock, None) + + grid_view.public = False + grid_view.allow_public_export = True + grid_view.save() + + with pytest.raises(PermissionDenied): + run_export_job_with_mock_storage(table, grid_view, storage_mock, None) + + +@pytest.mark.django_db +@patch("baserow.core.storage.get_default_storage") +def test_exporting_public_view_without_user(get_storage_mock, data_fixture): + storage_mock = MagicMock() + get_storage_mock.return_value = storage_mock + table = data_fixture.create_database_table() + text_field = data_fixture.create_text_field(table=table, name="text_field", order=1) + grid_view = data_fixture.create_grid_view( + table=table, public=True, allow_public_export=True + ) + model = table.get_model() + model.objects.create( + **{ + f"field_{text_field.id}": "hello", + }, + ) + _, contents = run_export_job_with_mock_storage(table, grid_view, storage_mock, None) + bom = "\ufeff" + expected = bom + "id,text_field\r\n" "1,hello\r\n" + assert contents == expected + + @pytest.mark.django_db @patch("baserow.core.storage.get_default_storage") def test_columns_are_exported_by_order_then_field_id(get_storage_mock, data_fixture): diff --git a/backend/tests/baserow/contrib/database/view/test_view_webhook_event_types.py b/backend/tests/baserow/contrib/database/view/test_view_webhook_event_types.py index 0e36188fa..f2f556078 100644 --- a/backend/tests/baserow/contrib/database/view/test_view_webhook_event_types.py +++ b/backend/tests/baserow/contrib/database/view/test_view_webhook_event_types.py @@ -42,6 +42,7 @@ def test_view_created_event_type(data_fixture): "filters_disabled": False, "public_view_has_password": False, "show_logo": True, + "allow_public_export": False, "ownership_type": "collaborative", "owned_by_id": None, "row_identifier_type": "id", @@ -84,6 +85,7 @@ def test_view_created_event_type_test_payload(data_fixture): "filters_disabled": False, "public_view_has_password": False, "show_logo": True, + "allow_public_export": False, "ownership_type": "collaborative", "owned_by_id": None, "row_identifier_type": "id", @@ -135,6 +137,7 @@ def test_view_updated_event_type(data_fixture): "filters_disabled": False, "public_view_has_password": False, "show_logo": True, + "allow_public_export": False, "ownership_type": "collaborative", "owned_by_id": None, "row_identifier_type": "id", @@ -177,6 +180,7 @@ def test_view_updated_event_type_test_payload(data_fixture): "filters_disabled": False, "public_view_has_password": False, "show_logo": True, + "allow_public_export": False, "ownership_type": "collaborative", "owned_by_id": None, "row_identifier_type": "id", diff --git a/changelog/entries/unreleased/feature/1213_public_view_export.json b/changelog/entries/unreleased/feature/1213_public_view_export.json new file mode 100644 index 000000000..fbd9977d1 --- /dev/null +++ b/changelog/entries/unreleased/feature/1213_public_view_export.json @@ -0,0 +1,7 @@ +{ + "type": "feature", + "message": "Allow optionally exporting in publicly shared views.", + "issue_number": 1213, + "bullet_points": [], + "created_at": "2025-02-03" +} diff --git a/enterprise/backend/src/baserow_enterprise/audit_log/handler.py b/enterprise/backend/src/baserow_enterprise/audit_log/handler.py index 1e9b40900..ff7b15a4e 100755 --- a/enterprise/backend/src/baserow_enterprise/audit_log/handler.py +++ b/enterprise/backend/src/baserow_enterprise/audit_log/handler.py @@ -51,7 +51,7 @@ class AuditLogHandler: ip_address = get_user_remote_addr_ip(user) return AuditLogEntry.objects.create( - user_id=user.id, + user_id=getattr(user, "id", None), user_email=getattr(user, "email", None), workspace_id=workspace_id, workspace_name=workspace_name, diff --git a/premium/backend/src/baserow_premium/api/views/serializers.py b/premium/backend/src/baserow_premium/api/views/serializers.py index a51689f64..72d7947a8 100644 --- a/premium/backend/src/baserow_premium/api/views/serializers.py +++ b/premium/backend/src/baserow_premium/api/views/serializers.py @@ -3,3 +3,4 @@ from rest_framework import serializers class UpdatePremiumViewAttributesSerializer(serializers.Serializer): show_logo = serializers.BooleanField(required=False) + allow_public_export = serializers.BooleanField(required=False) diff --git a/premium/backend/src/baserow_premium/api/views/signers.py b/premium/backend/src/baserow_premium/api/views/signers.py new file mode 100644 index 000000000..a6336c4ed --- /dev/null +++ b/premium/backend/src/baserow_premium/api/views/signers.py @@ -0,0 +1,7 @@ +from django.conf import settings + +from itsdangerous import URLSafeTimedSerializer + +export_public_view_signer = URLSafeTimedSerializer( + settings.SECRET_KEY, "export-public-view" +) diff --git a/premium/backend/src/baserow_premium/api/views/urls.py b/premium/backend/src/baserow_premium/api/views/urls.py index f5e22331f..b86057ea6 100644 --- a/premium/backend/src/baserow_premium/api/views/urls.py +++ b/premium/backend/src/baserow_premium/api/views/urls.py @@ -1,6 +1,10 @@ from django.urls import re_path -from baserow_premium.api.views.views import PremiumViewAttributesView +from baserow_premium.api.views.views import ( + ExportPublicViewJobView, + ExportPublicViewView, + PremiumViewAttributesView, +) app_name = "baserow_premium.api.views" @@ -10,4 +14,14 @@ urlpatterns = [ PremiumViewAttributesView.as_view(), name="premium_view_attributes", ), + re_path( + r"(?P<slug>[-\w]+)/export-public-view/$", + ExportPublicViewView.as_view(), + name="export_public_view", + ), + re_path( + r"get-public-view-export/(?P<job_id>[-\w.]+)/$", + ExportPublicViewJobView.as_view(), + name="get_public_view_export", + ), ] diff --git a/premium/backend/src/baserow_premium/api/views/views.py b/premium/backend/src/baserow_premium/api/views/views.py index 12d2cd04c..75705feec 100644 --- a/premium/backend/src/baserow_premium/api/views/views.py +++ b/premium/backend/src/baserow_premium/api/views/views.py @@ -1,25 +1,60 @@ from typing import Dict from urllib.request import Request +from django.db import transaction + from baserow_premium.api.views.errors import ( ERROR_CANNOT_UPDATE_PREMIUM_ATTRIBUTES_ON_TEMPLATE, ) from baserow_premium.api.views.exceptions import CannotUpdatePremiumAttributesOnTemplate from baserow_premium.api.views.serializers import UpdatePremiumViewAttributesSerializer +from baserow_premium.api.views.signers import export_public_view_signer from baserow_premium.license.features import PREMIUM from baserow_premium.license.handler import LicenseHandler from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, extend_schema +from itsdangerous.exc import BadData +from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework.views import APIView from baserow.api.decorators import map_exceptions, validate_body from baserow.api.errors import ERROR_USER_NOT_IN_GROUP from baserow.api.schemas import get_error_schema -from baserow.contrib.database.api.views.errors import ERROR_VIEW_DOES_NOT_EXIST +from baserow.contrib.database.api.export.errors import ERROR_EXPORT_JOB_DOES_NOT_EXIST +from baserow.contrib.database.api.export.serializers import ExportJobSerializer +from baserow.contrib.database.api.export.views import ( + CreateExportJobSerializer, + _validate_options, +) +from baserow.contrib.database.api.fields.errors import ( + ERROR_FILTER_FIELD_NOT_FOUND, + ERROR_ORDER_BY_FIELD_NOT_FOUND, + ERROR_ORDER_BY_FIELD_NOT_POSSIBLE, +) +from baserow.contrib.database.api.views.errors import ( + ERROR_NO_AUTHORIZATION_TO_PUBLICLY_SHARED_VIEW, + ERROR_VIEW_DOES_NOT_EXIST, + ERROR_VIEW_FILTER_TYPE_DOES_NOT_EXIST, + ERROR_VIEW_FILTER_TYPE_UNSUPPORTED_FIELD, +) from baserow.contrib.database.api.views.serializers import ViewSerializer +from baserow.contrib.database.api.views.utils import get_public_view_authorization_token +from baserow.contrib.database.export.exceptions import ExportJobDoesNotExistException +from baserow.contrib.database.export.handler import ExportHandler +from baserow.contrib.database.export.models import ExportJob +from baserow.contrib.database.fields.exceptions import ( + FilterFieldNotFound, + OrderByFieldNotFound, + OrderByFieldNotPossible, +) from baserow.contrib.database.views.actions import UpdateViewActionType -from baserow.contrib.database.views.exceptions import ViewDoesNotExist +from baserow.contrib.database.views.exceptions import ( + NoAuthorizationToPubliclySharedView, + ViewDoesNotExist, + ViewFilterTypeDoesNotExist, + ViewFilterTypeNotAllowedForField, +) from baserow.contrib.database.views.handler import ViewHandler from baserow.contrib.database.views.registries import view_type_registry from baserow.core.action.registries import action_type_registry @@ -98,3 +133,125 @@ class PremiumViewAttributesView(APIView): view, ViewSerializer, context={"user": request.user} ) return Response(serializer.data) + + +class ExportPublicViewView(APIView): + permission_classes = (AllowAny,) + + @extend_schema( + parameters=[ + OpenApiParameter( + name="slug", + location=OpenApiParameter.PATH, + type=OpenApiTypes.STR, + description="Select the view you want to export.", + ), + ], + tags=["Database table view export"], + operation_id="export_publicly_shared_view", + description=( + "Creates and starts a new export job for a publicly shared view given " + "some exporter options. Returns an error if the view doesn't support " + "exporting." + "\n\nThis is a **premium** feature." + ), + request=CreateExportJobSerializer, + responses={ + 200: ExportJobSerializer, + 400: get_error_schema( + [ + "ERROR_REQUEST_BODY_VALIDATION", + "ERROR_TABLE_ONLY_EXPORT_UNSUPPORTED", + "ERROR_VIEW_UNSUPPORTED_FOR_EXPORT_TYPE", + "ERROR_VIEW_NOT_IN_TABLE", + "ERROR_FILTER_FIELD_NOT_FOUND", + "ERROR_VIEW_FILTER_TYPE_DOES_NOT_EXIST", + "ERROR_VIEW_FILTER_TYPE_UNSUPPORTED_FIELD", + "ERROR_ORDER_BY_FIELD_NOT_FOUND", + "ERROR_ORDER_BY_FIELD_NOT_POSSIBLE", + ] + ), + 404: get_error_schema(["ERROR_VIEW_DOES_NOT_EXIST"]), + }, + ) + @transaction.atomic + @map_exceptions( + { + UserNotInWorkspace: ERROR_USER_NOT_IN_GROUP, + ViewDoesNotExist: ERROR_VIEW_DOES_NOT_EXIST, + NoAuthorizationToPubliclySharedView: ERROR_NO_AUTHORIZATION_TO_PUBLICLY_SHARED_VIEW, + FilterFieldNotFound: ERROR_FILTER_FIELD_NOT_FOUND, + ViewFilterTypeDoesNotExist: ERROR_VIEW_FILTER_TYPE_DOES_NOT_EXIST, + ViewFilterTypeNotAllowedForField: ERROR_VIEW_FILTER_TYPE_UNSUPPORTED_FIELD, + OrderByFieldNotFound: ERROR_ORDER_BY_FIELD_NOT_FOUND, + OrderByFieldNotPossible: ERROR_ORDER_BY_FIELD_NOT_POSSIBLE, + } + ) + def post(self, request, slug): + """ + Starts a new export job for the provided table, view, export type and options. + """ + + view_handler = ViewHandler() + authorization_token = get_public_view_authorization_token(request) + view = view_handler.get_public_view_by_slug( + request.user, slug, authorization_token=authorization_token + ).specific + table = view.table + option_data = _validate_options(request.data) + + # Delete the provided view ID because it can be identified using the slug + # path parameter. + del option_data["view_id"] + + job = ExportHandler.create_and_start_new_job(None, table, view, option_data) + serialized_job = ExportJobSerializer(job).data + serialized_job["id"] = export_public_view_signer.dumps(serialized_job["id"]) + return Response(serialized_job) + + +class ExportPublicViewJobView(APIView): + permission_classes = (AllowAny,) + + @extend_schema( + parameters=[ + OpenApiParameter( + name="job_id", + location=OpenApiParameter.PATH, + type=OpenApiTypes.STR, + description="The signed job id to lookup information about.", + ) + ], + tags=["Database table view export"], + operation_id="get_public_view_export_job", + description=( + "Returns information such as export progress and state or the url of the " + "exported file for the specified export job, only if the requesting user " + "has access." + "\n\nThis is a **premium** feature." + ), + request=None, + responses={ + 200: ExportJobSerializer, + 404: get_error_schema(["ERROR_EXPORT_JOB_DOES_NOT_EXIST"]), + }, + ) + @map_exceptions( + { + ExportJobDoesNotExistException: ERROR_EXPORT_JOB_DOES_NOT_EXIST, + BadData: ERROR_EXPORT_JOB_DOES_NOT_EXIST, + } + ) + def get(self, request, job_id): + """Retrieves the specified export job by serialized id.""" + + job_id = export_public_view_signer.loads(job_id) + + try: + job = ExportJob.objects.get(id=job_id, user=None) + except ExportJob.DoesNotExist: + raise ExportJobDoesNotExistException() + + serialized_job = ExportJobSerializer(job).data + serialized_job["id"] = export_public_view_signer.dumps(serialized_job["id"]) + return Response(serialized_job) diff --git a/premium/backend/src/baserow_premium/export/exporter_types.py b/premium/backend/src/baserow_premium/export/exporter_types.py index 733affdb4..627aa38d6 100644 --- a/premium/backend/src/baserow_premium/export/exporter_types.py +++ b/premium/backend/src/baserow_premium/export/exporter_types.py @@ -10,6 +10,7 @@ from baserow.contrib.database.api.export.serializers import ( ) from baserow.contrib.database.export.file_writer import FileWriter, QuerysetSerializer from baserow.contrib.database.export.registries import TableExporter +from baserow.contrib.database.export.utils import view_is_publicly_exportable from baserow.contrib.database.views.view_types import GridViewType from ..license.features import PREMIUM @@ -23,9 +24,15 @@ class PremiumTableExporter(TableExporter): Checks if the related user access to a valid license before the job is created. """ - LicenseHandler.raise_if_user_doesnt_have_feature( - PREMIUM, user, table.database.workspace - ) + if view_is_publicly_exportable(user, view): + # No need to check if the workspace has the license if the view is + # publicly exportable because then we should always allow it, regardless + # of the license. + pass + else: + LicenseHandler.raise_if_user_doesnt_have_feature( + PREMIUM, user, table.database.workspace + ) super().before_job_create(user, table, view, export_options) diff --git a/premium/backend/tests/baserow_premium_tests/api/views/views/test_calendar_views.py b/premium/backend/tests/baserow_premium_tests/api/views/views/test_calendar_views.py index 56058c72b..c5aad4e54 100644 --- a/premium/backend/tests/baserow_premium_tests/api/views/views/test_calendar_views.py +++ b/premium/backend/tests/baserow_premium_tests/api/views/views/test_calendar_views.py @@ -1003,6 +1003,7 @@ def test_get_public_calendar_view_with_single_select_and_cover( "type": "calendar", "date_field": date_field.id, "show_logo": True, + "allow_public_export": False, "ical_public": False, "ical_feed_url": calendar_view.ical_feed_url, }, diff --git a/premium/backend/tests/baserow_premium_tests/api/views/views/test_kanban_views.py b/premium/backend/tests/baserow_premium_tests/api/views/views/test_kanban_views.py index 8bef277a9..a1462c7f5 100644 --- a/premium/backend/tests/baserow_premium_tests/api/views/views/test_kanban_views.py +++ b/premium/backend/tests/baserow_premium_tests/api/views/views/test_kanban_views.py @@ -1587,6 +1587,7 @@ def test_get_public_kanban_without_with_single_select_and_cover( "card_cover_image_field": None, "single_select_field": None, "show_logo": True, + "allow_public_export": False, }, } @@ -1695,6 +1696,7 @@ def test_get_public_kanban_view_with_single_select_and_cover( "card_cover_image_field": cover_field.id, "single_select_field": single_select_field.id, "show_logo": True, + "allow_public_export": False, }, } diff --git a/premium/backend/tests/baserow_premium_tests/api/views/views/test_premium_view_attributes_view.py b/premium/backend/tests/baserow_premium_tests/api/views/views/test_premium_view_attributes_view.py index eeb5ce7c7..ca738dc43 100644 --- a/premium/backend/tests/baserow_premium_tests/api/views/views/test_premium_view_attributes_view.py +++ b/premium/backend/tests/baserow_premium_tests/api/views/views/test_premium_view_attributes_view.py @@ -37,7 +37,41 @@ def test_premium_view_attributes_view(view_type, api_client, premium_data_fixtur ) assert response.status_code == HTTP_200_OK - assert response.json()["show_logo"] is False + response_json = response.json() + assert response_json["show_logo"] is False + assert response_json["allow_public_export"] is False + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +@pytest.mark.parametrize("view_type", view_type_registry.registry.keys()) +def test_premium_view_attributes_view_allow_public_export( + view_type, api_client, premium_data_fixture +): + user, token = premium_data_fixture.create_user_and_token( + email="test@test.nl", + password="password", + first_name="Test1", + has_active_premium_license=True, + ) + table = premium_data_fixture.create_database_table(user=user) + view = ViewHandler().create_view( + user=user, table=table, type_name=view_type, name=view_type + ) + + response = api_client.patch( + reverse( + "api:premium:view:premium_view_attributes", kwargs={"view_id": view.id} + ), + data={"allow_public_export": True}, + format="json", + **{"HTTP_AUTHORIZATION": f"JWT {token}"}, + ) + + assert response.status_code == HTTP_200_OK + response_json = response.json() + assert response_json["show_logo"] is True + assert response_json["allow_public_export"] is True @pytest.mark.django_db diff --git a/premium/backend/tests/baserow_premium_tests/api/views/views/test_preview_public_view_export.py b/premium/backend/tests/baserow_premium_tests/api/views/views/test_preview_public_view_export.py new file mode 100644 index 000000000..897884e0b --- /dev/null +++ b/premium/backend/tests/baserow_premium_tests/api/views/views/test_preview_public_view_export.py @@ -0,0 +1,837 @@ +from unittest.mock import patch + +from django.conf import settings +from django.core.files.storage import FileSystemStorage +from django.shortcuts import reverse +from django.test.utils import override_settings + +import pytest +from baserow_premium.api.views.signers import export_public_view_signer +from rest_framework.status import ( + HTTP_200_OK, + HTTP_400_BAD_REQUEST, + HTTP_401_UNAUTHORIZED, + HTTP_404_NOT_FOUND, +) + +from baserow.contrib.database.export.models import ExportJob + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_create_public_view_export_of_not_existing_view( + api_client, premium_data_fixture +): + response = api_client.post( + reverse( + "api:premium:view:export_public_view", kwargs={"slug": "does_not_exist"} + ), + data={ + "exporter_type": "csv", + "export_charset": "utf-8", + "csv_include_header": "True", + "csv_column_separator": ",", + }, + format="json", + ) + response_json = response.json() + assert response.status_code == HTTP_404_NOT_FOUND + assert response_json["error"] == "ERROR_VIEW_DOES_NOT_EXIST" + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_create_public_view_export_of_not_public_view(api_client, premium_data_fixture): + grid = premium_data_fixture.create_grid_view(public=False) + + response = api_client.post( + reverse("api:premium:view:export_public_view", kwargs={"slug": grid.slug}), + data={ + "exporter_type": "csv", + "export_charset": "utf-8", + "csv_include_header": "True", + "csv_column_separator": ",", + }, + format="json", + ) + response_json = response.json() + assert response.status_code == HTTP_404_NOT_FOUND + assert response_json["error"] == "ERROR_VIEW_DOES_NOT_EXIST" + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_create_public_view_export_respecting_view_password( + api_client, premium_data_fixture +): + ( + grid, + public_view_token, + ) = premium_data_fixture.create_public_password_protected_grid_view_with_token( + password="12345678", + allow_public_export=True, + ) + + response = api_client.post( + reverse("api:premium:view:export_public_view", kwargs={"slug": grid.slug}), + data={ + "exporter_type": "csv", + "export_charset": "utf-8", + "csv_include_header": "True", + "csv_column_separator": ",", + }, + format="json", + ) + assert response.status_code == HTTP_401_UNAUTHORIZED + + response = api_client.post( + reverse("api:premium:view:export_public_view", kwargs={"slug": grid.slug}), + data={ + "exporter_type": "csv", + "export_charset": "utf-8", + "csv_include_header": "True", + "csv_column_separator": ",", + }, + format="json", + HTTP_BASEROW_VIEW_AUTHORIZATION=f"JWT {public_view_token}", + ) + assert response.status_code == HTTP_200_OK + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_create_public_view_export( + api_client, premium_data_fixture, django_capture_on_commit_callbacks, tmpdir +): + grid = premium_data_fixture.create_grid_view(public=True, allow_public_export=True) + + storage = FileSystemStorage(location=(str(tmpdir)), base_url="http://localhost") + + with patch("baserow.core.storage.get_default_storage") as get_storage_mock: + get_storage_mock.return_value = storage + + with django_capture_on_commit_callbacks(execute=True): + response = api_client.post( + reverse( + "api:premium:view:export_public_view", kwargs={"slug": grid.slug} + ), + data={ + "exporter_type": "csv", + "export_charset": "utf-8", + "csv_include_header": "True", + "csv_column_separator": ",", + }, + format="json", + ) + response_json = response.json() + assert response.status_code == HTTP_200_OK + + job = ExportJob.objects.all().first() + del response_json["created_at"] + + job_id = response_json.pop("id") + assert export_public_view_signer.loads(job_id) == job.id + + assert response_json == { + "table": grid.table_id, + "view": grid.id, + "exporter_type": "csv", + "state": "pending", + "status": "pending", + "exported_file_name": None, + "progress_percentage": 0.0, + "url": None, + } + + response = api_client.get( + reverse( + "api:premium:view:get_public_view_export", kwargs={"job_id": job_id} + ), + ) + response_json = response.json() + + job_id = response_json.pop("id") + del response_json["created_at"] + assert export_public_view_signer.loads(job_id) == job.id + filename = response_json["exported_file_name"] + assert response_json == { + "table": grid.table_id, + "view": grid.id, + "exporter_type": "csv", + "state": "finished", + "status": "finished", + "exported_file_name": filename, + "progress_percentage": 100.0, + "url": f"http://localhost:8000/media/export_files/{filename}", + } + + file_path = tmpdir.join(settings.EXPORT_FILES_DIRECTORY, filename) + assert file_path.isfile() + expected = "\ufeff" "id\n" + with open(file_path, "r", encoding="utf-8") as written_file: + assert written_file.read() == expected + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_create_public_view_export_respecting_view_visible_fields( + api_client, premium_data_fixture, django_capture_on_commit_callbacks, tmpdir +): + storage = FileSystemStorage(location=(str(tmpdir)), base_url="http://localhost") + + user = premium_data_fixture.create_user() + table = premium_data_fixture.create_database_table(user=user) + text_field = premium_data_fixture.create_text_field( + table=table, name="text_field", order=1 + ) + grid_view = premium_data_fixture.create_grid_view( + table=table, public=True, allow_public_export=True + ) + hidden_text_field = premium_data_fixture.create_text_field( + table=table, name="text_field", order=2 + ) + model = table.get_model() + model.objects.create( + **{ + f"field_{text_field.id}": "Something", + f"field_{hidden_text_field.id}": "Should be hidden", + }, + ) + premium_data_fixture.create_grid_view_field_option( + grid_view=grid_view, field=hidden_text_field, hidden=True + ) + + with patch("baserow.core.storage.get_default_storage") as get_storage_mock: + get_storage_mock.return_value = storage + + with django_capture_on_commit_callbacks(execute=True): + response = api_client.post( + reverse( + "api:premium:view:export_public_view", + kwargs={"slug": grid_view.slug}, + ), + data={ + "exporter_type": "csv", + "export_charset": "utf-8", + "csv_include_header": "True", + "csv_column_separator": ",", + }, + format="json", + ) + + job_id = response.json().pop("id") + response = api_client.get( + reverse( + "api:premium:view:get_public_view_export", kwargs={"job_id": job_id} + ), + ) + response_json = response.json() + filename = response_json["exported_file_name"] + + file_path = tmpdir.join(settings.EXPORT_FILES_DIRECTORY, filename) + assert file_path.isfile() + expected = "\ufeff" "id,text_field\n1,Something\n" + with open(file_path, "r", encoding="utf-8") as written_file: + assert written_file.read() == expected + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_create_public_view_export_respecting_view_filters( + api_client, premium_data_fixture, django_capture_on_commit_callbacks, tmpdir +): + storage = FileSystemStorage(location=(str(tmpdir)), base_url="http://localhost") + + user = premium_data_fixture.create_user() + table = premium_data_fixture.create_database_table(user=user) + text_field = premium_data_fixture.create_text_field( + table=table, name="text_field", order=1 + ) + grid_view = premium_data_fixture.create_grid_view( + table=table, public=True, allow_public_export=True + ) + premium_data_fixture.create_view_filter( + view=grid_view, field=text_field, type="contains", value="world" + ) + model = table.get_model() + model.objects.create( + **{ + f"field_{text_field.id}": "hello", + }, + ) + model.objects.create( + **{ + f"field_{text_field.id}": "world", + }, + ) + + with patch("baserow.core.storage.get_default_storage") as get_storage_mock: + get_storage_mock.return_value = storage + + with django_capture_on_commit_callbacks(execute=True): + response = api_client.post( + reverse( + "api:premium:view:export_public_view", + kwargs={"slug": grid_view.slug}, + ), + data={ + "exporter_type": "csv", + "export_charset": "utf-8", + "csv_include_header": "True", + "csv_column_separator": ",", + }, + format="json", + ) + + job_id = response.json().pop("id") + response = api_client.get( + reverse( + "api:premium:view:get_public_view_export", kwargs={"job_id": job_id} + ), + ) + response_json = response.json() + filename = response_json["exported_file_name"] + + file_path = tmpdir.join(settings.EXPORT_FILES_DIRECTORY, filename) + assert file_path.isfile() + expected = "\ufeff" "id,text_field\n2,world\n" + with open(file_path, "r", encoding="utf-8") as written_file: + assert written_file.read() == expected + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_create_public_view_export_respecting_ad_hoc_filters( + api_client, premium_data_fixture, django_capture_on_commit_callbacks, tmpdir +): + storage = FileSystemStorage(location=(str(tmpdir)), base_url="http://localhost") + + user = premium_data_fixture.create_user() + table = premium_data_fixture.create_database_table(user=user) + text_field = premium_data_fixture.create_text_field( + table=table, name="text_field", order=1 + ) + grid_view = premium_data_fixture.create_grid_view( + table=table, public=True, allow_public_export=True + ) + model = table.get_model() + model.objects.create( + **{ + f"field_{text_field.id}": "hello", + }, + ) + model.objects.create( + **{ + f"field_{text_field.id}": "world", + }, + ) + + with patch("baserow.core.storage.get_default_storage") as get_storage_mock: + get_storage_mock.return_value = storage + + with django_capture_on_commit_callbacks(execute=True): + response = api_client.post( + reverse( + "api:premium:view:export_public_view", + kwargs={"slug": grid_view.slug}, + ), + data={ + "exporter_type": "csv", + "export_charset": "utf-8", + "csv_include_header": "True", + "csv_column_separator": ",", + "filters": { + "filter_type": "AND", + "filters": [ + { + "type": "contains", + "field": text_field.id, + "value": "world", + } + ], + "groups": [], + }, + "order_by": "", + }, + format="json", + ) + + job_id = response.json().pop("id") + response = api_client.get( + reverse( + "api:premium:view:get_public_view_export", kwargs={"job_id": job_id} + ), + ) + response_json = response.json() + filename = response_json["exported_file_name"] + + file_path = tmpdir.join(settings.EXPORT_FILES_DIRECTORY, filename) + assert file_path.isfile() + expected = "\ufeff" "id,text_field\n2,world\n" + with open(file_path, "r", encoding="utf-8") as written_file: + assert written_file.read() == expected + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_create_public_view_export_malformed_ad_hoc_filters( + api_client, premium_data_fixture, django_capture_on_commit_callbacks, tmpdir +): + storage = FileSystemStorage(location=(str(tmpdir)), base_url="http://localhost") + + user = premium_data_fixture.create_user() + table = premium_data_fixture.create_database_table(user=user) + text_field = premium_data_fixture.create_text_field( + table=table, name="text_field", order=1 + ) + grid_view = premium_data_fixture.create_grid_view( + table=table, public=True, allow_public_export=True + ) + model = table.get_model() + model.objects.create( + **{ + f"field_{text_field.id}": "hello", + }, + ) + model.objects.create( + **{ + f"field_{text_field.id}": "world", + }, + ) + + with patch("baserow.core.storage.get_default_storage") as get_storage_mock: + get_storage_mock.return_value = storage + + with django_capture_on_commit_callbacks(execute=True): + response = api_client.post( + reverse( + "api:premium:view:export_public_view", + kwargs={"slug": grid_view.slug}, + ), + data={ + "exporter_type": "csv", + "export_charset": "utf-8", + "csv_include_header": "True", + "csv_column_separator": ",", + "filters": {"test": ""}, + "order_by": "", + }, + format="json", + ) + assert response.status_code == HTTP_400_BAD_REQUEST + assert response.json()["error"] == "ERROR_REQUEST_BODY_VALIDATION" + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_create_public_view_export_respecting_ad_hoc_order_by( + api_client, premium_data_fixture, django_capture_on_commit_callbacks, tmpdir +): + storage = FileSystemStorage(location=(str(tmpdir)), base_url="http://localhost") + + user = premium_data_fixture.create_user() + table = premium_data_fixture.create_database_table(user=user) + text_field = premium_data_fixture.create_text_field( + table=table, name="text_field", order=1 + ) + grid_view = premium_data_fixture.create_grid_view( + table=table, public=True, allow_public_export=True + ) + model = table.get_model() + model.objects.create( + **{ + f"field_{text_field.id}": "hello", + }, + ) + model.objects.create( + **{ + f"field_{text_field.id}": "world", + }, + ) + + with patch("baserow.core.storage.get_default_storage") as get_storage_mock: + get_storage_mock.return_value = storage + + with django_capture_on_commit_callbacks(execute=True): + response = api_client.post( + reverse( + "api:premium:view:export_public_view", + kwargs={"slug": grid_view.slug}, + ), + data={ + "exporter_type": "csv", + "export_charset": "utf-8", + "csv_include_header": "True", + "csv_column_separator": ",", + "filters": None, + "order_by": f"-field_{text_field.id}", + }, + format="json", + ) + print(response.json()) + + job_id = response.json().pop("id") + response = api_client.get( + reverse( + "api:premium:view:get_public_view_export", kwargs={"job_id": job_id} + ), + ) + response_json = response.json() + filename = response_json["exported_file_name"] + + file_path = tmpdir.join(settings.EXPORT_FILES_DIRECTORY, filename) + assert file_path.isfile() + expected = "\ufeff" "id,text_field\n2,world\n1,hello\n" + with open(file_path, "r", encoding="utf-8") as written_file: + assert written_file.read() == expected + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_create_public_view_export_malformed_order_by( + api_client, premium_data_fixture, django_capture_on_commit_callbacks, tmpdir +): + storage = FileSystemStorage(location=(str(tmpdir)), base_url="http://localhost") + + user = premium_data_fixture.create_user() + table = premium_data_fixture.create_database_table(user=user) + text_field = premium_data_fixture.create_text_field( + table=table, name="text_field", order=1 + ) + grid_view = premium_data_fixture.create_grid_view( + table=table, public=True, allow_public_export=True + ) + model = table.get_model() + model.objects.create( + **{ + f"field_{text_field.id}": "hello", + }, + ) + model.objects.create( + **{ + f"field_{text_field.id}": "world", + }, + ) + + with patch("baserow.core.storage.get_default_storage") as get_storage_mock: + get_storage_mock.return_value = storage + + with django_capture_on_commit_callbacks(execute=True): + response = api_client.post( + reverse( + "api:premium:view:export_public_view", + kwargs={"slug": grid_view.slug}, + ), + data={ + "exporter_type": "csv", + "export_charset": "utf-8", + "csv_include_header": "True", + "csv_column_separator": ",", + "filters": None, + "order_by": f"TEST", + }, + format="json", + ) + assert response.status_code == HTTP_400_BAD_REQUEST + assert response.json()["error"] == "ERROR_ORDER_BY_FIELD_NOT_FOUND" + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_create_public_view_export_respecting_include_visible_fields_in_order( + api_client, premium_data_fixture, django_capture_on_commit_callbacks, tmpdir +): + storage = FileSystemStorage(location=(str(tmpdir)), base_url="http://localhost") + + user = premium_data_fixture.create_user() + table = premium_data_fixture.create_database_table(user=user) + text_field = premium_data_fixture.create_text_field( + table=table, name="text_field", order=1 + ) + text_field_2 = premium_data_fixture.create_text_field( + table=table, name="text_field2", order=2 + ) + text_field_3 = premium_data_fixture.create_text_field( + table=table, name="text_field3", order=2 + ) + grid_view = premium_data_fixture.create_grid_view( + table=table, public=True, allow_public_export=True + ) + + with patch("baserow.core.storage.get_default_storage") as get_storage_mock: + get_storage_mock.return_value = storage + + with django_capture_on_commit_callbacks(execute=True): + response = api_client.post( + reverse( + "api:premium:view:export_public_view", + kwargs={"slug": grid_view.slug}, + ), + data={ + "exporter_type": "csv", + "export_charset": "utf-8", + "csv_include_header": "True", + "csv_column_separator": ",", + "fields": [text_field_2.id, text_field.id], + }, + format="json", + ) + + job_id = response.json().pop("id") + response = api_client.get( + reverse( + "api:premium:view:get_public_view_export", kwargs={"job_id": job_id} + ), + ) + response_json = response.json() + filename = response_json["exported_file_name"] + + file_path = tmpdir.join(settings.EXPORT_FILES_DIRECTORY, filename) + assert file_path.isfile() + expected = "\ufeff" "id,text_field2,text_field\n" + with open(file_path, "r", encoding="utf-8") as written_file: + assert written_file.read() == expected + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_create_public_view_export_respecting_include_visible_fields_in_order_wrong_field_id( + api_client, premium_data_fixture, django_capture_on_commit_callbacks, tmpdir +): + storage = FileSystemStorage(location=(str(tmpdir)), base_url="http://localhost") + + user = premium_data_fixture.create_user() + table = premium_data_fixture.create_database_table(user=user) + text_field = premium_data_fixture.create_text_field( + table=table, name="text_field", order=1 + ) + text_field_2 = premium_data_fixture.create_text_field( + table=table, name="text_field2", order=2 + ) + text_field_3 = premium_data_fixture.create_text_field( + table=table, name="text_field3", order=2 + ) + grid_view = premium_data_fixture.create_grid_view( + table=table, public=True, allow_public_export=True + ) + + with patch("baserow.core.storage.get_default_storage") as get_storage_mock: + get_storage_mock.return_value = storage + + with django_capture_on_commit_callbacks(execute=True): + response = api_client.post( + reverse( + "api:premium:view:export_public_view", + kwargs={"slug": grid_view.slug}, + ), + data={ + "exporter_type": "csv", + "export_charset": "utf-8", + "csv_include_header": "True", + "csv_column_separator": ",", + "fields": [9999999, text_field_2.id, text_field.id], + }, + format="json", + ) + + job_id = response.json().pop("id") + response = api_client.get( + reverse( + "api:premium:view:get_public_view_export", kwargs={"job_id": job_id} + ), + ) + response_json = response.json() + filename = response_json["exported_file_name"] + + file_path = tmpdir.join(settings.EXPORT_FILES_DIRECTORY, filename) + assert file_path.isfile() + expected = "\ufeff" "id,text_field2,text_field\n" + with open(file_path, "r", encoding="utf-8") as written_file: + assert written_file.read() == expected + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_create_public_view_export_error_hidden_fields_in_order_by( + api_client, premium_data_fixture, django_capture_on_commit_callbacks, tmpdir +): + storage = FileSystemStorage(location=(str(tmpdir)), base_url="http://localhost") + + user = premium_data_fixture.create_user() + table = premium_data_fixture.create_database_table(user=user) + text_field = premium_data_fixture.create_text_field( + table=table, name="text_field", order=1 + ) + grid_view = premium_data_fixture.create_grid_view( + table=table, public=True, allow_public_export=True + ) + hidden_text_field = premium_data_fixture.create_text_field( + table=table, name="text_field", order=2 + ) + premium_data_fixture.create_grid_view_field_option( + grid_view=grid_view, field=hidden_text_field, hidden=True + ) + + with patch("baserow.core.storage.get_default_storage") as get_storage_mock: + get_storage_mock.return_value = storage + + with django_capture_on_commit_callbacks(execute=True): + response = api_client.post( + reverse( + "api:premium:view:export_public_view", + kwargs={"slug": grid_view.slug}, + ), + data={ + "exporter_type": "csv", + "export_charset": "utf-8", + "csv_include_header": "True", + "csv_column_separator": ",", + "order_by": f"field_{hidden_text_field.id}", + }, + format="json", + ) + + assert response.status_code == HTTP_400_BAD_REQUEST + assert response.json()["error"] == "ERROR_ORDER_BY_FIELD_NOT_FOUND" + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_create_public_view_export_can_sort_by_manually_hidden_view( + api_client, premium_data_fixture, django_capture_on_commit_callbacks, tmpdir +): + storage = FileSystemStorage(location=(str(tmpdir)), base_url="http://localhost") + + user = premium_data_fixture.create_user() + table = premium_data_fixture.create_database_table(user=user) + text_field = premium_data_fixture.create_text_field( + table=table, name="text_field", order=1 + ) + grid_view = premium_data_fixture.create_grid_view( + table=table, public=True, allow_public_export=True + ) + hidden_text_field = premium_data_fixture.create_text_field( + table=table, name="text_field", order=2 + ) + premium_data_fixture.create_grid_view_field_option( + grid_view=grid_view, field=text_field, hidden=False + ) + premium_data_fixture.create_grid_view_field_option( + grid_view=grid_view, field=hidden_text_field, hidden=False + ) + + with patch("baserow.core.storage.get_default_storage") as get_storage_mock: + get_storage_mock.return_value = storage + + with django_capture_on_commit_callbacks(execute=True): + response = api_client.post( + reverse( + "api:premium:view:export_public_view", + kwargs={"slug": grid_view.slug}, + ), + data={ + "exporter_type": "csv", + "export_charset": "utf-8", + "csv_include_header": "True", + "csv_column_separator": ",", + "order_by": f"field_{hidden_text_field.id}", + "fields": [text_field.id], + }, + format="json", + ) + + assert response.status_code == HTTP_200_OK + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_create_public_view_export_error_hidden_fields_in_filters( + api_client, premium_data_fixture, django_capture_on_commit_callbacks, tmpdir +): + storage = FileSystemStorage(location=(str(tmpdir)), base_url="http://localhost") + + user = premium_data_fixture.create_user() + table = premium_data_fixture.create_database_table(user=user) + text_field = premium_data_fixture.create_text_field( + table=table, name="text_field", order=1 + ) + grid_view = premium_data_fixture.create_grid_view( + table=table, public=True, allow_public_export=True + ) + hidden_text_field = premium_data_fixture.create_text_field( + table=table, name="text_field", order=2 + ) + premium_data_fixture.create_grid_view_field_option( + grid_view=grid_view, field=text_field, hidden=False + ) + premium_data_fixture.create_grid_view_field_option( + grid_view=grid_view, field=hidden_text_field, hidden=True + ) + model = table.get_model() + model.objects.create( + **{ + f"field_{hidden_text_field.id}": "hello", + }, + ) + model.objects.create( + **{ + f"field_{hidden_text_field.id}": "world", + }, + ) + + with patch("baserow.core.storage.get_default_storage") as get_storage_mock: + get_storage_mock.return_value = storage + + with django_capture_on_commit_callbacks(execute=True): + response = api_client.post( + reverse( + "api:premium:view:export_public_view", + kwargs={"slug": grid_view.slug}, + ), + data={ + "exporter_type": "csv", + "export_charset": "utf-8", + "csv_include_header": "True", + "csv_column_separator": ",", + "filters": { + "filter_type": "AND", + "filters": [ + { + "type": "contains", + "field": hidden_text_field.id, + "value": "world", + } + ], + "groups": [], + }, + }, + format="json", + ) + + assert response.status_code == HTTP_400_BAD_REQUEST + assert response.json()["error"] == "ERROR_FILTER_FIELD_NOT_FOUND" + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_get_public_view_export_job_not_found(api_client, premium_data_fixture): + response = api_client.get( + reverse( + "api:premium:view:get_public_view_export", + kwargs={"job_id": export_public_view_signer.dumps(0)}, + ), + ) + response_json = response.json() + assert response.status_code == HTTP_404_NOT_FOUND + assert response_json["error"] == "ERROR_EXPORT_JOB_DOES_NOT_EXIST" + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_get_public_view_export_invalid_signed_id(api_client, premium_data_fixture): + response = api_client.get( + reverse("api:premium:view:get_public_view_export", kwargs={"job_id": "test"}), + ) + response_json = response.json() + assert response.status_code == HTTP_404_NOT_FOUND + assert response_json["error"] == "ERROR_EXPORT_JOB_DOES_NOT_EXIST" diff --git a/premium/backend/tests/baserow_premium_tests/api/views/views/test_timeline_views.py b/premium/backend/tests/baserow_premium_tests/api/views/views/test_timeline_views.py index b512b3a0a..771f8d775 100644 --- a/premium/backend/tests/baserow_premium_tests/api/views/views/test_timeline_views.py +++ b/premium/backend/tests/baserow_premium_tests/api/views/views/test_timeline_views.py @@ -1171,6 +1171,7 @@ def test_get_public_timeline_view(api_client, premium_data_fixture): "id": PUBLIC_PLACEHOLDER_ENTITY_ID, }, "show_logo": True, + "allow_public_export": False, "type": "timeline", "start_date_field": start_date_field.id, "end_date_field": end_date_field.id, diff --git a/premium/web-frontend/modules/baserow_premium/components/views/BaserowLogoShareLinkOption.vue b/premium/web-frontend/modules/baserow_premium/components/views/BaserowLogoShareLinkOption.vue deleted file mode 100644 index 42119e116..000000000 --- a/premium/web-frontend/modules/baserow_premium/components/views/BaserowLogoShareLinkOption.vue +++ /dev/null @@ -1,88 +0,0 @@ -<template> - <div - v-tooltip="tooltipText" - class="view-sharing__option" - :class="{ 'view-sharing__option--disabled': !hasPremiumFeatures }" - @click="click" - > - <SwitchInput - small - :value="!view.show_logo" - :disabled="!hasPremiumFeatures" - @input="update" - > - <img src="@baserow/modules/core/static/img/baserow-icon.svg" /> - <span> - {{ $t('shareLinkOptions.baserowLogo.label') }} - </span> - <i v-if="!hasPremiumFeatures" class="deactivated-label iconoir-lock" /> - </SwitchInput> - - <PremiumModal - v-if="!hasPremiumFeatures" - ref="premiumModal" - :workspace="workspace" - :name="$t('shareLinkOptions.baserowLogo.premiumModalName')" - ></PremiumModal> - </div> -</template> - -<script> -import { mapGetters } from 'vuex' -import ViewPremiumService from '@baserow_premium/services/view' -import { notifyIf } from '@baserow/modules/core/utils/error' -import PremiumModal from '@baserow_premium/components/PremiumModal' -import PremiumFeatures from '@baserow_premium/features' - -export default { - name: 'BaserowLogoShareLinkOption', - components: { PremiumModal }, - - props: { - view: { - type: Object, - required: true, - }, - }, - computed: { - ...mapGetters({ - additionalUserData: 'auth/getAdditionalUserData', - }), - workspace() { - return this.$store.getters['application/get'](this.view.table.database_id) - .workspace - }, - hasPremiumFeatures() { - return this.$hasFeature(PremiumFeatures.PREMIUM, this.workspace.id) - }, - tooltipText() { - if (this.hasPremiumFeatures) { - return null - } else { - return this.$t('premium.deactivated') - } - }, - }, - methods: { - async update(value) { - const showLogo = !value - try { - // We are being optimistic that the request will succeed. - this.$emit('update-view', { ...this.view, show_logo: showLogo }) - await ViewPremiumService(this.$client).update(this.view.id, { - show_logo: showLogo, - }) - } catch (error) { - // In case it didn't we will roll back the change. - this.$emit('update-view', { ...this.view, show_logo: !showLogo }) - notifyIf(error, 'view') - } - }, - click() { - if (!this.hasPremiumFeatures) { - this.$refs.premiumModal.show() - } - }, - }, -} -</script> diff --git a/premium/web-frontend/modules/baserow_premium/components/views/PremiumViewOptions.vue b/premium/web-frontend/modules/baserow_premium/components/views/PremiumViewOptions.vue new file mode 100644 index 000000000..1862eee6c --- /dev/null +++ b/premium/web-frontend/modules/baserow_premium/components/views/PremiumViewOptions.vue @@ -0,0 +1,113 @@ +<template> + <div> + <div + v-tooltip="tooltipText" + class="view-sharing__option" + :class="{ 'view-sharing__option--disabled': !hasPremiumFeatures }" + @click="click" + > + <SwitchInput + small + :value="!view.show_logo" + :disabled="!hasPremiumFeatures" + @input="update('show_logo', !$event)" + > + <img src="@baserow/modules/core/static/img/baserow-icon.svg" /> + <span> + {{ $t('shareLinkOptions.baserowLogo.label') }} + </span> + <i v-if="!hasPremiumFeatures" class="deactivated-label iconoir-lock" /> + </SwitchInput> + + <PremiumModal + v-if="!hasPremiumFeatures" + ref="premiumModal" + :workspace="workspace" + :name="$t('shareLinkOptions.baserowLogo.premiumModalName')" + ></PremiumModal> + </div> + <div + v-if="hasValidExporter" + v-tooltip="tooltipText" + class="view-sharing__option" + :class="{ 'view-sharing__option--disabled': !hasPremiumFeatures }" + @click="click" + > + <SwitchInput + small + :value="view.allow_public_export" + :disabled="!hasPremiumFeatures" + @input="update('allow_public_export', $event)" + > + <i class="iconoir iconoir-share-ios"></i> + <span> + {{ $t('shareLinkOptions.allowPublicExportLabel') }} + </span> + <i v-if="!hasPremiumFeatures" class="deactivated-label iconoir-lock" /> + </SwitchInput> + </div> + </div> +</template> + +<script> +import { mapGetters } from 'vuex' +import ViewPremiumService from '@baserow_premium/services/view' +import { notifyIf } from '@baserow/modules/core/utils/error' +import PremiumModal from '@baserow_premium/components/PremiumModal' +import PremiumFeatures from '@baserow_premium/features' +import viewTypeHasExporterTypes from '@baserow/modules/database/utils/viewTypeHasExporterTypes' + +export default { + name: 'PremiumViewOptions', + components: { PremiumModal }, + + props: { + view: { + type: Object, + required: true, + }, + }, + computed: { + ...mapGetters({ + additionalUserData: 'auth/getAdditionalUserData', + }), + workspace() { + return this.$store.getters['application/get'](this.view.table.database_id) + .workspace + }, + hasPremiumFeatures() { + return this.$hasFeature(PremiumFeatures.PREMIUM, this.workspace.id) + }, + tooltipText() { + if (this.hasPremiumFeatures) { + return null + } else { + return this.$t('premium.deactivated') + } + }, + hasValidExporter() { + return viewTypeHasExporterTypes(this.view.type, this.$registry) + }, + }, + methods: { + async update(key, value) { + try { + // We are being optimistic that the request will succeed. + this.$emit('update-view', { ...this.view, [key]: value }) + await ViewPremiumService(this.$client).update(this.view.id, { + [key]: value, + }) + } catch (error) { + // In case it didn't we will roll back the change. + this.$emit('update-view', { ...this.view, [key]: !value }) + notifyIf(error, 'view') + } + }, + click() { + if (!this.hasPremiumFeatures) { + this.$refs.premiumModal.show() + } + }, + }, +} +</script> diff --git a/premium/web-frontend/modules/baserow_premium/components/views/PublicViewExport.vue b/premium/web-frontend/modules/baserow_premium/components/views/PublicViewExport.vue new file mode 100644 index 000000000..f9f056f43 --- /dev/null +++ b/premium/web-frontend/modules/baserow_premium/components/views/PublicViewExport.vue @@ -0,0 +1,125 @@ +<template> + <li + v-if="view.allow_public_export" + class="header__filter-item header__filter-item--no-margin-left" + > + <a + ref="target" + class="header__filter-link" + @click="$refs.context.toggle($event.target, 'bottom', 'left', 4)" + > + <i class="header__filter-icon baserow-icon-more-vertical"></i> + </a> + <Context ref="context"> + <ul class="context__menu"> + <li class="context__menu-item"> + <a + class="context__menu-item-link" + @click=";[$refs.exportModal.show(), $refs.context.hide()]" + > + <i class="context__menu-item-icon iconoir-share-ios"></i> + {{ $t('publicViewExport.export') }} + </a> + </li> + </ul> + </Context> + <ExportTableModal + ref="exportModal" + :view="view" + :table="table" + :database="database" + :start-export="startExport" + :get-job="getJob" + :enable-views-dropdown="false" + :ad-hoc-filtering="true" + :ad-hoc-sorting="true" + :ad-hoc-fields="visibleOrderedFields" + ></ExportTableModal> + </li> +</template> + +<script> +import ExportTableModal from '@baserow/modules/database/components/export/ExportTableModal' +import PublicViewExportService from '@baserow_premium/services/publicViewExport' +import { + createFiltersTree, + getOrderBy, +} from '@baserow/modules/database/utils/view' + +export default { + name: 'PublicViewExport', + components: { ExportTableModal }, + props: { + database: { + type: Object, + required: true, + }, + table: { + type: Object, + required: true, + }, + view: { + type: Object, + required: true, + }, + fields: { + type: Array, + required: true, + }, + isPublicView: { + type: Boolean, + required: true, + }, + storePrefix: { + type: String, + required: true, + }, + }, + computed: { + visibleOrderedFields() { + const viewType = this.$registry.get('view', this.view.type) + return viewType.getVisibleFieldsInOrder( + this, + this.fields, + this.view, + this.storePrefix + ) + }, + }, + methods: { + startExport({ view, values, client }) { + // There is no need to include the `view_id` in the body because we're already + // providing the slug as path parameter. + delete values.view_id + + let filters = null + const filterTree = createFiltersTree( + this.view.filter_type, + this.view.filters, + this.view.filter_groups + ) + filters = filterTree.getFiltersTreeSerialized() + values.filters = filters + + const orderBy = getOrderBy(this.view, true) + values.order_by = orderBy + + values.fields = + this.visibleOrderedFields === null + ? null + : this.visibleOrderedFields.map((f) => f.id) + + const publicAuthToken = + this.$store.getters['page/view/public/getAuthToken'] + return PublicViewExportService(client).export({ + slug: view.slug, + values, + publicAuthToken, + }) + }, + getJob(job, client) { + return PublicViewExportService(client).get(job.id) + }, + }, +} +</script> diff --git a/premium/web-frontend/modules/baserow_premium/locales/en.json b/premium/web-frontend/modules/baserow_premium/locales/en.json index 57f21db4a..ba0b1c8fd 100644 --- a/premium/web-frontend/modules/baserow_premium/locales/en.json +++ b/premium/web-frontend/modules/baserow_premium/locales/en.json @@ -276,7 +276,8 @@ "baserowLogo": { "label": "Hide Baserow logo on shared view", "premiumModalName": "public logo removal" - } + }, + "allowPublicExportLabel": "Allow export on shared view" }, "viewsContext": { "personal": "Personal" @@ -337,5 +338,8 @@ "textDescription": "Generates free text based on the prompt.", "choice": "Choice", "choiceDescription": "Chooses only one of the field options." + }, + "publicViewExport": { + "export": "Export" } } diff --git a/premium/web-frontend/modules/baserow_premium/plugins.js b/premium/web-frontend/modules/baserow_premium/plugins.js index f9e9ccb36..a35c0d1b8 100644 --- a/premium/web-frontend/modules/baserow_premium/plugins.js +++ b/premium/web-frontend/modules/baserow_premium/plugins.js @@ -1,7 +1,8 @@ import { BaserowPlugin } from '@baserow/modules/core/plugins' import Impersonate from '@baserow_premium/components/sidebar/Impersonate' import HighestLicenseTypeBadge from '@baserow_premium/components/sidebar/HighestLicenseTypeBadge' -import BaserowLogoShareLinkOption from '@baserow_premium/components/views/BaserowLogoShareLinkOption' +import PremiumViewOptions from '@baserow_premium/components/views/PremiumViewOptions' +import PublicViewExport from '@baserow_premium/components/views/PublicViewExport' export class PremiumPlugin extends BaserowPlugin { static getType() { @@ -17,7 +18,11 @@ export class PremiumPlugin extends BaserowPlugin { } getAdditionalShareLinkOptions() { - return [BaserowLogoShareLinkOption] + return [PremiumViewOptions] + } + + getAdditionalTableHeaderComponents(view, isPublic) { + return isPublic ? [PublicViewExport] : [] } hasFeature(feature, forSpecificWorkspace) { diff --git a/premium/web-frontend/modules/baserow_premium/services/publicViewExport.js b/premium/web-frontend/modules/baserow_premium/services/publicViewExport.js new file mode 100644 index 000000000..63b4b51de --- /dev/null +++ b/premium/web-frontend/modules/baserow_premium/services/publicViewExport.js @@ -0,0 +1,24 @@ +import addPublicAuthTokenHeader from '@baserow/modules/database/utils/publicView' + +export default (client) => { + return { + export({ slug, values, publicAuthToken = null }) { + const config = {} + + if (publicAuthToken) { + addPublicAuthTokenHeader(config, publicAuthToken) + } + + return client.post( + `/database/view/${slug}/export-public-view/`, + { + ...values, + }, + config + ) + }, + get(jobId) { + return client.get(`/database/view/get-public-view-export/${jobId}/`) + }, + } +} diff --git a/premium/web-frontend/modules/baserow_premium/tableExporterTypes.js b/premium/web-frontend/modules/baserow_premium/tableExporterTypes.js index b39f516f7..0e121e07d 100644 --- a/premium/web-frontend/modules/baserow_premium/tableExporterTypes.js +++ b/premium/web-frontend/modules/baserow_premium/tableExporterTypes.js @@ -16,7 +16,12 @@ class PremiumTableExporterType extends TableExporterType { } isDeactivated(workspaceId) { - return !this.app.$hasFeature(PremiumFeatures.PREMIUM, workspaceId) + // If the user is looking a publicly shared view, then the feature must never be + // deactivated because the check can't be done properly. + const isPublic = this.app.store.getters['page/view/public/getIsPublic'] + return ( + !this.app.$hasFeature(PremiumFeatures.PREMIUM, workspaceId) && !isPublic + ) } } diff --git a/web-frontend/modules/core/plugins.js b/web-frontend/modules/core/plugins.js index fedfe5f7f..c009fcb4e 100644 --- a/web-frontend/modules/core/plugins.js +++ b/web-frontend/modules/core/plugins.js @@ -114,6 +114,14 @@ export class BaserowPlugin extends Registerable { return [] } + /** + * Every registered plugin can display multiple components to the head of the table + * header. This will be positioned directly next to the name of the view. + */ + getAdditionalTableHeaderComponents(view, isPublic) { + return [] + } + /** * Every registered plugin can display multiple additional context items in the * application context displayed by the sidebar when opening the context menu of a diff --git a/web-frontend/modules/database/components/export/ExportTableForm.vue b/web-frontend/modules/database/components/export/ExportTableForm.vue index 3148fac7f..88bb8c36d 100644 --- a/web-frontend/modules/database/components/export/ExportTableForm.vue +++ b/web-frontend/modules/database/components/export/ExportTableForm.vue @@ -3,6 +3,7 @@ <div class="row"> <div class="col col-12"> <FormGroup + v-if="enableViewsDropdown" small-label :label="$t('exportTableForm.viewLabel')" required @@ -75,6 +76,11 @@ export default { type: Boolean, required: true, }, + enableViewsDropdown: { + type: Boolean, + required: false, + default: true, + }, }, data() { return { diff --git a/web-frontend/modules/database/components/export/ExportTableModal.vue b/web-frontend/modules/database/components/export/ExportTableModal.vue index df9d19568..f92c14ae2 100644 --- a/web-frontend/modules/database/components/export/ExportTableModal.vue +++ b/web-frontend/modules/database/components/export/ExportTableModal.vue @@ -13,6 +13,7 @@ :view="view" :views="views" :loading="loading" + :enable-views-dropdown="enableViewsDropdown" @submitted="submitted" @values-changed="valuesChanged" > @@ -56,6 +57,25 @@ export default { required: false, default: null, }, + startExport: { + type: Function, + required: false, + default: function ({ table, values, client }) { + return ExporterService(client).export(table.id, values) + }, + }, + getJob: { + type: Function, + required: false, + default: function (job, client) { + return ExporterService(client).get(job.id) + }, + }, + enableViewsDropdown: { + type: Boolean, + required: false, + default: true, + }, }, data() { return { @@ -121,10 +141,12 @@ export default { this.hideError() try { - const { data } = await ExporterService(this.$client).export( - this.table.id, - values - ) + const { data } = await this.startExport({ + table: this.table, + view: this.view, + values, + client: this.$client, + }) this.job = data if (this.pollInterval !== null) { clearInterval(this.pollInterval) @@ -136,7 +158,7 @@ export default { }, async getLatestJobInfo() { try { - const { data } = await ExporterService(this.$client).get(this.job.id) + const { data } = await this.getJob(this.job, this.$client) this.job = data if (!this.jobIsRunning) { this.loading = false diff --git a/web-frontend/modules/database/components/table/Table.vue b/web-frontend/modules/database/components/table/Table.vue index c5fb7c9e0..e82da0345 100644 --- a/web-frontend/modules/database/components/table/Table.vue +++ b/web-frontend/modules/database/components/table/Table.vue @@ -33,7 +33,10 @@ <span class="header__filter-name header__filter-name--forced"> <EditableViewName ref="rename" :view="view"></EditableViewName> </span> - <i class="header__sub-icon iconoir-nav-arrow-down"></i> + <i + v-if="views !== null" + class="header__sub-icon iconoir-nav-arrow-down" + ></i> </template> <template v-else-if="view !== null"> {{ $t('table.chooseView') }} @@ -77,6 +80,21 @@ > </ViewContext> </li> + <component + :is="component" + v-for="(component, index) in getAdditionalTableHeaderComponents( + view, + isPublic + )" + :key="index" + :database="database" + :table="table" + :view="view" + :fields="fields" + :is-public-view="isPublic" + :store-prefix="storePrefix" + > + </component> <li v-if=" hasSelectedView && @@ -446,6 +464,17 @@ export default { const type = this.$registry.get('view', view.type) return type.getHeaderComponent() }, + getAdditionalTableHeaderComponents(view, isPublic) { + const opts = Object.values(this.$registry.getAll('plugin')) + .reduce((components, plugin) => { + components = components.concat( + plugin.getAdditionalTableHeaderComponents(view, isPublic) + ) + return components + }, []) + .filter((component) => component !== null) + return opts + }, /** * When the window resizes, we want to check if the content of the header is * overflowing. If that is the case, we want to make some space by removing some diff --git a/web-frontend/modules/database/viewTypes.js b/web-frontend/modules/database/viewTypes.js index b131f73a9..f1d4a765f 100644 --- a/web-frontend/modules/database/viewTypes.js +++ b/web-frontend/modules/database/viewTypes.js @@ -8,9 +8,11 @@ import FormView from '@baserow/modules/database/components/view/form/FormView' import FormViewHeader from '@baserow/modules/database/components/view/form/FormViewHeader' import { FileFieldType } from '@baserow/modules/database/fieldTypes' import { + filterVisibleFieldsFunction, isAdhocFiltering, isAdhocSorting, newFieldMatchesActiveSearchTerm, + sortFieldsByOrderAndIdFunction, } from '@baserow/modules/database/utils/view' import { clone } from '@baserow/modules/core/utils/object' import { getDefaultSearchModeFromEnv } from '@baserow/modules/database/utils/search' @@ -483,6 +485,16 @@ export class ViewType extends Registerable { isCompatibleWithDataSync(dataSync) { return true } + + getVisibleFieldsInOrder({ $store: store }, fields, view, storePrefix = '') { + const fieldOptions = + store.getters[ + storePrefix + 'view/' + this.getType() + '/getAllFieldOptions' + ] + return fields + .filter(filterVisibleFieldsFunction(fieldOptions)) + .sort(sortFieldsByOrderAndIdFunction(fieldOptions, true)) + } } export class GridViewType extends ViewType { diff --git a/web-frontend/test/unit/database/__snapshots__/publicView.spec.js.snap b/web-frontend/test/unit/database/__snapshots__/publicView.spec.js.snap index 78fd44497..ba6e01a66 100644 --- a/web-frontend/test/unit/database/__snapshots__/publicView.spec.js.snap +++ b/web-frontend/test/unit/database/__snapshots__/publicView.spec.js.snap @@ -93,16 +93,14 @@ exports[`Public View Page Tests Can see a publicly shared grid view 1`] = ` </span> </span> - <i - class="header__sub-icon iconoir-nav-arrow-down" - /> + <!----> </a> <!----> </li> <!----> - + <li class="header__filter-item" >