1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-10 23:50:12 +00:00

Merge branch '1911_ical_feed' into 'develop'

1911 ical feed

See merge request 
This commit is contained in:
Cezary Statkiewicz 2024-07-23 14:19:27 +00:00
commit b99f8bbb6f
50 changed files with 2701 additions and 187 deletions

View file

@ -128,6 +128,7 @@ DATABASE_NAME=baserow
# BASEROW_MAX_CONCURRENT_USER_REQUESTS=
# BASEROW_CONCURRENT_USER_REQUESTS_THROTTLE_TIMEOUT=
# BASEROW_SEND_VERIFY_EMAIL_RATE_LIMIT=
# BASEROW_ICAL_VIEW_MAX_EVENTS=
# BASEROW_ENTERPRISE_AUDIT_LOG_CLEANUP_INTERVAL_MINUTES=
# BASEROW_ENTERPRISE_AUDIT_LOG_RETENTION_DAYS=

View file

@ -73,3 +73,5 @@ langchain==0.1.17
openai==1.30.1
jsonschema==4.17.3 # pinned due to version conflict
# referencing==0.31.1 # pinned because of version conflict
# premium ical feed feature
icalendar==5.0.12

View file

@ -2,7 +2,7 @@
# This file is autogenerated by pip-compile with Python 3.11
# by the following command:
#
# pip-compile --output-file=base.txt base.in
# pip-compile base.in
#
advocate==1.0.0
# via -r base.in
@ -232,6 +232,8 @@ hyperlink==21.0.0
# via
# autobahn
# twisted
icalendar==5.0.12
# via -r base.in
idna==3.7
# via
# anyio
@ -486,6 +488,7 @@ python-dateutil==2.9.0.post0
# celery
# celery-redbeat
# faker
# icalendar
# posthog
# pysaml2
# python-crontab
@ -494,6 +497,7 @@ python-dotenv==1.0.1
pytz==2024.1
# via
# flower
# icalendar
# pysaml2
pyyaml==6.0.1
# via

View file

@ -2,7 +2,7 @@
# This file is autogenerated by pip-compile with Python 3.11
# by the following command:
#
# pip-compile --output-file=dev.txt dev.in
# pip-compile dev.in
#
argh==0.31.2
# via -r dev.in
@ -96,6 +96,10 @@ graphviz==0.20.3
# via -r dev.in
httpretty==1.1.4
# via -r dev.in
icalendar==5.0.12
# via
# -c base.txt
# -r dev.in
icdiff==2.0.7
# via pytest-icdiff
idna==3.7
@ -255,6 +259,11 @@ python-dateutil==2.9.0.post0
# via
# -c base.txt
# freezegun
# icalendar
pytz==2024.1
# via
# -c base.txt
# icalendar
pyyaml==6.0.1
# via
# -c base.txt

View file

@ -24,6 +24,7 @@ from baserow.config.settings.utils import (
read_file,
set_settings_from_env_if_present,
str_to_bool,
try_int,
)
from baserow.core.telemetry.utils import otel_is_enabled
from baserow.throttling_types import RateLimit
@ -960,6 +961,13 @@ INITIAL_MIGRATION_FULL_TEXT_SEARCH_MAX_FIELD_LIMIT = int(
)
)
# set max events to be returned by every ICal feed. Empty value means no limit.
BASEROW_ICAL_VIEW_MAX_EVENTS = try_int(
os.getenv("BASEROW_ICAL_VIEW_MAX_EVENTS", None), None
)
# If you change this default please also update the default for the web-frontend found
# in web-frontend/modules/core/module.js:55
HOURS_UNTIL_TRASH_PERMANENTLY_DELETED = int(

View file

@ -116,6 +116,13 @@ def str_to_bool(s: str) -> bool:
return s.lower().strip() in ("y", "yes", "t", "true", "on", "1")
def try_int(s: str | int | None, default: Any = None) -> int | None:
try:
return int(s)
except (TypeError, ValueError):
return default
def get_crontab_from_env(env_var_name: str, default_crontab: str) -> crontab:
"""
Parses a crontab from an environment variable if present or instead uses the

View file

@ -439,7 +439,7 @@ class ViewView(APIView):
tags=["Database table views"],
operation_id="get_database_table_view",
description=(
"Returns the existing view. Depending on the type different properties"
"Returns the existing view. Depending on the type different properties "
"could be returned."
),
responses={

View file

@ -641,16 +641,19 @@ class ViewHandler(metaclass=baserow_trace_methods(tracer)):
def get_view(
self,
view_id: int,
view_id: int | str,
view_model: Optional[Type[View]] = None,
base_queryset: Optional[QuerySet] = None,
table_id: Optional[int] = None,
pk_field: str = "pk",
) -> View:
"""
Selects a view and checks if the user has access to that view.
If everything is fine the view is returned.
:param view_id: The identifier of the view that must be returned.
:param view_id: The identifier of the view that must be returned. By default
it's primary key value, but `pk_field` param allows to query by another
unique field.
: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.
@ -659,6 +662,8 @@ class ViewHandler(metaclass=baserow_trace_methods(tracer)):
if this is used the `view_model` parameter doesn't work anymore.
:params table_id: The table id of the view. This is used to check if the
view is in the table. If not provided the view is not checked.
:param pk_field: name of unique field to query for `view_id` value.
`'pk'` by default,
:raises ViewDoesNotExist: When the view with the provided id does not exist.
:return: the view instance.
"""
@ -671,7 +676,7 @@ class ViewHandler(metaclass=baserow_trace_methods(tracer)):
try:
view = base_queryset.select_related("table__database__workspace").get(
pk=view_id
**{pk_field: view_id}
)
except View.DoesNotExist as exc:
raise ViewDoesNotExist(
@ -2959,7 +2964,9 @@ class ViewHandler(metaclass=baserow_trace_methods(tracer)):
return queryset.aggregate(**aggregation_dict)
def rotate_view_slug(self, user: AbstractUser, view: View) -> View:
def rotate_view_slug(
self, user: AbstractUser, view: View, slug_field: str = "slug"
) -> View:
"""
Rotates the slug of the provided view.
@ -2969,9 +2976,11 @@ class ViewHandler(metaclass=baserow_trace_methods(tracer)):
"""
new_slug = View.create_new_slug()
return self.update_view_slug(user, view, new_slug)
return self.update_view_slug(user, view, new_slug, slug_field)
def update_view_slug(self, user: AbstractUser, view: View, slug: str) -> View:
def update_view_slug(
self, user: AbstractUser, view: View, slug: str, slug_field: str = "slug"
) -> View:
"""
Updates the slug of the provided view.
@ -2993,7 +3002,7 @@ class ViewHandler(metaclass=baserow_trace_methods(tracer)):
)
old_view = deepcopy(view)
view.slug = slug
setattr(view, slug_field, slug)
view.save()
view_updated.send(self, view=view, user=user, old_view=old_view)

View file

@ -157,7 +157,7 @@ class View(
Rotates the slug used to address this view.
"""
self.slug = secrets.token_urlsafe()
self.slug = View.create_new_slug()
@staticmethod
def create_new_slug() -> str:

View file

@ -58,7 +58,7 @@ from .exceptions import (
if TYPE_CHECKING:
from baserow.contrib.database.fields.models import Field
from baserow.contrib.database.table.models import Table
from baserow.contrib.database.table.models import FieldObject, Table
from baserow.contrib.database.views.models import FormView, View
@ -481,7 +481,7 @@ class ViewType(
def get_visible_fields_and_model(
self, view: "View"
) -> Tuple[List["Field"], django_models.Model]:
) -> Tuple[List["FieldObject"], django_models.Model]:
"""
Returns the field objects for the provided view. Depending on the view type this
will only return the visible or appropriate fields as different view types can

View file

@ -0,0 +1,9 @@
{
"type": "feature",
"message": "[premium] Calendar view can be shared with external applications via ical feed url",
"issue_number": 1911,
"bullet_points": [
"added `BASEROW_ICAL_VIEW_MAX_EVENTS` setting to limit amount of events in the output"
],
"created_at": "2024-06-01"
}

View file

@ -46,6 +46,7 @@ export SYNC_TEMPLATES_ON_STARTUP=${SYNC_TEMPLATES_ON_STARTUP:-true}
export BASEROW_TRIGGER_SYNC_TEMPLATES_AFTER_MIGRATION=${BASEROW_TRIGGER_SYNC_TEMPLATES_AFTER_MIGRATION:-$SYNC_TEMPLATES_ON_STARTUP}
export MIGRATE_ON_STARTUP=${MIGRATE_ON_STARTUP:-true}
export MEDIA_ROOT="$DATA_DIR/media"
export BASEROW_ICAL_VIEW_MAX_EVENTS=${BASEROW_ICAL_VIEW_MAX_EVENTS:-}
export BASEROW_GROUP_STORAGE_USAGE_QUEUE="${BASEROW_GROUP_STORAGE_USAGE_QUEUE:-}"

View file

@ -148,6 +148,7 @@ x-backend-variables: &backend-variables
BASEROW_AUTO_VACUUM:
BASEROW_BUILDER_DOMAINS:
BASEROW_FRONTEND_SAME_SITE_COOKIE:
BASEROW_ICAL_VIEW_MAX_EVENTS: ${BASEROW_ICAL_VIEW_MAX_EVENTS:-}
services:
# A caddy reverse proxy sitting in-front of all the services. Responsible for routing

View file

@ -166,6 +166,7 @@ x-backend-variables: &backend-variables
BASEROW_USE_PG_FULLTEXT_SEARCH:
BASEROW_AUTO_VACUUM:
BASEROW_BUILDER_DOMAINS:
BASEROW_ICAL_VIEW_MAX_EVENTS: ${BASEROW_ICAL_VIEW_MAX_EVENTS:-}
services:
backend:

View file

@ -175,7 +175,7 @@ x-backend-variables: &backend-variables
BASEROW_SERVE_FILES_THROUGH_BACKEND:
BASEROW_SERVE_FILES_THROUGH_BACKEND_PERMISSION:
BASEROW_SERVE_FILES_THROUGH_BACKEND_EXPIRE_SECONDS:
BASEROW_ICAL_VIEW_MAX_EVENTS: ${BASEROW_ICAL_VIEW_MAX_EVENTS:-}
services:

View file

@ -126,33 +126,34 @@ The installation methods referred to in the variable descriptions are:
| BASEROW\_OLLAMA\_MODELS | Provide a comma separated list of Ollama models (https://ollama.com/library) that you would like to enable in the instance (e.g. `llama2`). Note that this only works if an Ollama host is set. If this variable is not provided, the user won't be able to choose a model. | |
### Backend Misc Configuration
| Name | Description | Defaults |
|----------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------|
| BASEROW\_ENABLE\_SECURE\_PROXY\_SSL\_HEADER | Set to any non-empty value to ensure Baserow generates https:// next links provided by paginated API endpoints. Baserow will still work correctly if not enabled, this is purely for giving the correct https url for clients of the API. If you have setup Baserow to use Caddy's auto HTTPS or you have put Baserow behind<br>a reverse proxy which:<br>* Handles HTTPS<br>* Strips the X-Forwarded-Proto header from all incoming requests.<br>* Sets the X-Forwarded-Proto header and sends it to Baserow.<br>Then you can safely set BASEROW\_ENABLE\_SECURE\_PROXY\_SSL\_HEADER=yes to ensure Baserow<br>generates https links for pagination correctly.<br> | |
| ADDITIONAL\_APPS | A comma separated list of additional django applications to add to the INSTALLED\_APPS django setting | |
| HOURS\_UNTIL\_TRASH\_PERMANENTLY\_DELETED | Items from the trash will be permanently deleted after this number of hours. | |
| DISABLE\_ANONYMOUS\_PUBLIC\_VIEW\_WS\_CONNECTIONS | When sharing views publicly a websocket connection is opened to provide realtime updates to viewers of the public link. To disable this set any non empty value. When disabled publicly shared links will need to be refreshed to see any updates to the view. | |
| BASEROW\_WAIT\_INSTEAD\_OF\_409\_CONFLICT\_ERROR | When updating or creating various resources in Baserow if another concurrent operation is ongoing (like a snapshot, duplication, import etc) which would be affected by your modification a 409 HTTP error will be returned. If you instead would prefer Baserow to not return a 409 and just block waiting until the operation finishes and then to perform the requested operation set this flag to any non-empty value. | |
| BASEROW\_JOB\_CLEANUP\_INTERVAL\_MINUTES | How often the job cleanup task will run. | 5 |
| BASEROW\_JOB\_EXPIRATION\_TIME\_LIMIT | How long before a Baserow job will be kept before being cleaned up. | 30 * 24 * 60 (24 days) |
| BASEROW\_JOB\_SOFT\_TIME\_LIMIT | The number of seconds a Baserow job can run before being terminated. | 1800 |
| BASEROW\_MAX\_FILE\_IMPORT\_ERROR\_COUNT | The max number of per row errors than can occur in a file import before an overall failure is declared | 30 |
| MINUTES\_UNTIL\_ACTION\_CLEANED\_UP | How long before actions are cleaned up, actions are used to let you undo/redo so this is effectively the max length of time you can undo/redo can action. | 120 |
| BASEROW\_DISABLE\_MODEL\_CACHE | When set to any non empty value the model cache used to speed up Baserow will be disabled. Useful to enable when debugging Baserow errors if they are possibly caused by the model cache itself. | | |
| BASEROW\_STORAGE\_USAGE\_JOB\_CRONTAB | The crontab controlling when the file usage job runs when enabled in the settings page | 0 0 * * * |
| BASEROW\_ROW\_COUNT\_JOB\_CRONTAB | The crontab controlling when the row counting job runs when enabled in the settings page | 0 3 * * * |
| | | |
| DJANGO\_SETTINGS\_MODULE | **INTERNAL** The settings python module to load when starting up the Backend django server. You shouldnt need to set this yourself unless you are customizing the settings manually. | |
| | | |
| BASEROW\_BACKEND\_BIND\_ADDRESS | **INTERNAL** The address that Baserows backend service will bind to. | |
| BASEROW\_BACKEND\_PORT | **INTERNAL** Controls which port the Baserow backend service binds to. | |
| BASEROW\_WEBFRONTEND\_BIND\_ADDRESS | **INTERNAL** The address that Baserows web-frontend service will bind to. | |
| BASEROW\_INITIAL\_CREATE\_SYNC\_TABLE\_DATA\_LIMIT | The maximum number of rows you can import in a synchronous way | 5000 |
| BASEROW\_MAX\_ROW\_REPORT\_ERROR\_COUNT | The maximum row error count tolerated before a file import fails. Before this max error count the import will continue and the non failing rows will be imported and after it, no rows are imported at all. | 30 |
| BASEROW\_ROW\_HISTORY\_CLEANUP\_INTERVAL\_MINUTES | Sets the interval for periodic clean up check of the row edit history in minutes. | 30 |
| BASEROW\_ROW\_HISTORY\_RETENTION\_DAYS | The number of days that the row edit history will be kept. | 180 |
| BASEROW\_ENTERPRISE\_AUDIT\_LOG\_CLEANUP\_INTERVAL_MINUTES | Sets the interval for periodic clean up check of the enterprise audit log in minutes. | 30 |
| BASEROW\_ENTERPRISE\_AUDIT\_LOG\_RETENTION\_DAYS | The number of days that the enterprise audit log will be kept. | 365 |
| Name | Description | Defaults |
|------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------|
| BASEROW\_ENABLE\_SECURE\_PROXY\_SSL\_HEADER | Set to any non-empty value to ensure Baserow generates https:// next links provided by paginated API endpoints. Baserow will still work correctly if not enabled, this is purely for giving the correct https url for clients of the API. If you have setup Baserow to use Caddy's auto HTTPS or you have put Baserow behind<br>a reverse proxy which:<br>* Handles HTTPS<br>* Strips the X-Forwarded-Proto header from all incoming requests.<br>* Sets the X-Forwarded-Proto header and sends it to Baserow.<br>Then you can safely set BASEROW\_ENABLE\_SECURE\_PROXY\_SSL\_HEADER=yes to ensure Baserow<br>generates https links for pagination correctly.<br> | |
| ADDITIONAL\_APPS | A comma separated list of additional django applications to add to the INSTALLED\_APPS django setting | |
| HOURS\_UNTIL\_TRASH\_PERMANENTLY\_DELETED | Items from the trash will be permanently deleted after this number of hours. | |
| DISABLE\_ANONYMOUS\_PUBLIC\_VIEW\_WS\_CONNECTIONS | When sharing views publicly a websocket connection is opened to provide realtime updates to viewers of the public link. To disable this set any non empty value. When disabled publicly shared links will need to be refreshed to see any updates to the view. | |
| BASEROW\_WAIT\_INSTEAD\_OF\_409\_CONFLICT\_ERROR | When updating or creating various resources in Baserow if another concurrent operation is ongoing (like a snapshot, duplication, import etc) which would be affected by your modification a 409 HTTP error will be returned. If you instead would prefer Baserow to not return a 409 and just block waiting until the operation finishes and then to perform the requested operation set this flag to any non-empty value. | |
| BASEROW\_JOB\_CLEANUP\_INTERVAL\_MINUTES | How often the job cleanup task will run. | 5 |
| BASEROW\_JOB\_EXPIRATION\_TIME\_LIMIT | How long before a Baserow job will be kept before being cleaned up. | 30 * 24 * 60 (24 days) |
| BASEROW\_JOB\_SOFT\_TIME\_LIMIT | The number of seconds a Baserow job can run before being terminated. | 1800 |
| BASEROW\_MAX\_FILE\_IMPORT\_ERROR\_COUNT | The max number of per row errors than can occur in a file import before an overall failure is declared | 30 |
| MINUTES\_UNTIL\_ACTION\_CLEANED\_UP | How long before actions are cleaned up, actions are used to let you undo/redo so this is effectively the max length of time you can undo/redo can action. | 120 |
| BASEROW\_DISABLE\_MODEL\_CACHE | When set to any non empty value the model cache used to speed up Baserow will be disabled. Useful to enable when debugging Baserow errors if they are possibly caused by the model cache itself. | | |
| BASEROW\_STORAGE\_USAGE\_JOB\_CRONTAB | The crontab controlling when the file usage job runs when enabled in the settings page | 0 0 * * * |
| BASEROW\_ROW\_COUNT\_JOB\_CRONTAB | The crontab controlling when the row counting job runs when enabled in the settings page | 0 3 * * * |
| | | |
| DJANGO\_SETTINGS\_MODULE | **INTERNAL** The settings python module to load when starting up the Backend django server. You shouldnt need to set this yourself unless you are customizing the settings manually. | |
| | | |
| BASEROW\_BACKEND\_BIND\_ADDRESS | **INTERNAL** The address that Baserows backend service will bind to. | |
| BASEROW\_BACKEND\_PORT | **INTERNAL** Controls which port the Baserow backend service binds to. | |
| BASEROW\_WEBFRONTEND\_BIND\_ADDRESS | **INTERNAL** The address that Baserows web-frontend service will bind to. | |
| BASEROW\_INITIAL\_CREATE\_SYNC\_TABLE\_DATA\_LIMIT | The maximum number of rows you can import in a synchronous way | 5000 |
| BASEROW\_MAX\_ROW\_REPORT\_ERROR\_COUNT | The maximum row error count tolerated before a file import fails. Before this max error count the import will continue and the non failing rows will be imported and after it, no rows are imported at all. | 30 |
| BASEROW\_ROW\_HISTORY\_CLEANUP\_INTERVAL\_MINUTES | Sets the interval for periodic clean up check of the row edit history in minutes. | 30 |
| BASEROW\_ROW\_HISTORY\_RETENTION\_DAYS | The number of days that the row edit history will be kept. | 180 |
| BASEROW\_ICAL\_VIEW\_MAX\_EVENTS | The maximum number of events returned from ical feed endpoint. Empty value means no limit. | |
| BASEROW\_ENTERPRISE\_AUDIT\_LOG\_CLEANUP\_INTERVAL_MINUTES | Sets the interval for periodic clean up check of the enterprise audit log in minutes. | 30 |
| BASEROW\_ENTERPRISE\_AUDIT\_LOG\_RETENTION\_DAYS | The number of days that the enterprise audit log will be kept. | 365 |

View file

@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
[project]
name = "baserow-premium"
authors = [{ name = "Bram Wiepjes (Baserow)", email="bram@baserow.io" }]
description="""Baserow is an open source no-code database tool and Airtable \
authors = [{ name = "Bram Wiepjes (Baserow)", email = "bram@baserow.io" }]
description = """Baserow is an open source no-code database tool and Airtable \
alternative. Easily create a relational database without any \
technical expertise. Build a table and define custom fields \
like text, number, file and many more."""

View file

@ -14,7 +14,10 @@ from baserow.core.datetime import get_timezones
class CalendarViewFieldOptionsSerializer(serializers.ModelSerializer):
class Meta:
model = CalendarViewFieldOptions
fields = ("hidden", "order")
fields = (
"hidden",
"order",
)
class ListCalendarRowsQueryParamsSerializer(serializers.Serializer):

View file

@ -1,6 +1,11 @@
from django.urls import re_path
from .views import CalendarViewView, PublicCalendarViewView
from .views import (
CalendarViewView,
ICalView,
PublicCalendarViewView,
RotateIcalFeedSlugView,
)
app_name = "baserow_premium.api.views.calendar"
@ -11,4 +16,14 @@ urlpatterns = [
PublicCalendarViewView.as_view(),
name="public_rows",
),
re_path(
r"(?P<view_id>[0-9]+)/ical_slug_rotate/$",
RotateIcalFeedSlugView.as_view(),
name="ical_slug_rotate",
),
re_path(
r"(?P<ical_slug>[-\w]+).ics$",
ICalView.as_view(),
name="calendar_ical_feed",
),
]

View file

@ -1,3 +1,7 @@
from django.conf import settings
from django.db import transaction
from django.http import HttpResponse
from baserow_premium.api.views.calendar.errors import (
ERROR_CALENDAR_VIEW_HAS_NO_DATE_FIELD,
)
@ -5,14 +9,17 @@ from baserow_premium.api.views.calendar.serializers import (
ListCalendarRowsQueryParamsSerializer,
get_calendar_view_example_response_serializer,
)
from baserow_premium.ical_utils import build_calendar
from baserow_premium.license.features import PREMIUM
from baserow_premium.license.handler import LicenseHandler
from baserow_premium.views.actions import RotateCalendarIcalSlugActionType
from baserow_premium.views.exceptions import CalendarViewHasNoDateField
from baserow_premium.views.handler import get_rows_grouped_by_date_field
from baserow_premium.views.models import CalendarView
from drf_spectacular.openapi import OpenApiParameter, OpenApiTypes
from drf_spectacular.utils import extend_schema
from rest_framework.permissions import AllowAny
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
@ -22,26 +29,36 @@ from baserow.api.decorators import (
validate_query_parameters,
)
from baserow.api.errors import ERROR_USER_NOT_IN_GROUP
from baserow.api.schemas import get_error_schema
from baserow.api.schemas import (
CLIENT_SESSION_ID_SCHEMA_PARAMETER,
CLIENT_UNDO_REDO_ACTION_GROUP_ID_SCHEMA_PARAMETER,
get_error_schema,
)
from baserow.api.utils import DiscriminatorCustomFieldsMappingSerializer
from baserow.contrib.database.api.constants import SEARCH_MODE_API_PARAM
from baserow.contrib.database.api.rows.serializers import (
RowSerializer,
get_row_serializer_class,
)
from baserow.contrib.database.api.views.errors import (
ERROR_CANNOT_SHARE_VIEW_TYPE,
ERROR_NO_AUTHORIZATION_TO_PUBLICLY_SHARED_VIEW,
ERROR_VIEW_DOES_NOT_EXIST,
)
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.rows.registries import row_metadata_registry
from baserow.contrib.database.table.operations import ListRowsDatabaseTableOperationType
from baserow.contrib.database.views.exceptions import (
CannotShareViewTypeError,
NoAuthorizationToPubliclySharedView,
ViewDoesNotExist,
)
from baserow.contrib.database.views.handler import ViewHandler
from baserow.contrib.database.views.registries import view_type_registry
from baserow.contrib.database.views.signals import view_loaded
from baserow.core.action.registries import action_type_registry
from baserow.core.db import specific_queryset
from baserow.core.exceptions import UserNotInWorkspace
from baserow.core.handler import CoreHandler
@ -232,7 +249,6 @@ class CalendarViewView(APIView):
table_model=model,
user=request.user,
)
return Response(response)
@ -379,3 +395,119 @@ class PublicCalendarViewView(APIView):
response.update(**serializer_class(view, context=context).data)
return Response(response)
class ICalView(APIView):
permission_classes = (AllowAny,)
@extend_schema(
parameters=[
OpenApiParameter(
name="ical_slug",
location=OpenApiParameter.PATH,
type=OpenApiTypes.STR,
required=True,
description="ICal feed unique slug.",
),
CLIENT_SESSION_ID_SCHEMA_PARAMETER,
],
tags=["Database table views"],
operation_id="calendar_ical_feed",
description=(
"Returns ICal feed for a specific Calendar view "
"identified by ical_slug value. "
"Calendar View resource contains full url in .ical_feed_url "
"field."
),
request=None,
responses={
(200, "text/calendar"): OpenApiTypes.BINARY,
400: get_error_schema(["ERROR_CALENDAR_VIEW_HAS_NO_DATE_FIELD"]),
404: get_error_schema(["ERROR_VIEW_DOES_NOT_EXIST"]),
},
)
@map_exceptions(
{
ViewDoesNotExist: ERROR_VIEW_DOES_NOT_EXIST,
CalendarViewHasNoDateField: ERROR_CALENDAR_VIEW_HAS_NO_DATE_FIELD,
}
)
def get(self, request, ical_slug: str):
view_handler = ViewHandler()
view: CalendarView = view_handler.get_view(
view_id=ical_slug,
view_model=CalendarView,
base_queryset=specific_queryset(
CalendarView.objects.select_related(
"date_field", "date_field__content_type", "table"
).prefetch_related("field_options")
),
pk_field="ical_slug",
)
if not view.ical_public:
raise ViewDoesNotExist()
qs = view_handler.get_queryset(view)
cal = build_calendar(qs, view, limit=settings.BASEROW_ICAL_VIEW_MAX_EVENTS)
return HttpResponse(
cal.to_ical(),
content_type="text/calendar",
headers={"Cache-Control": "max-age=1800"},
)
class RotateIcalFeedSlugView(APIView):
permission_classes = (IsAuthenticated,)
@extend_schema(
parameters=[
OpenApiParameter(
name="view_id",
location=OpenApiParameter.PATH,
type=OpenApiTypes.INT,
required=True,
description="Rotates the ical feed slug of the calendar view related to the provided "
"id.",
),
CLIENT_SESSION_ID_SCHEMA_PARAMETER,
CLIENT_UNDO_REDO_ACTION_GROUP_ID_SCHEMA_PARAMETER,
],
tags=["Database table views"],
operation_id="rotate_calendar_view_ical_feed_slug",
description=(
"Rotates the unique slug of the calendar view's ical feed 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."
),
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(
{
UserNotInWorkspace: ERROR_USER_NOT_IN_GROUP,
ViewDoesNotExist: ERROR_VIEW_DOES_NOT_EXIST,
CannotShareViewTypeError: ERROR_CANNOT_SHARE_VIEW_TYPE,
}
)
@transaction.atomic
def post(self, request: Request, view_id: int) -> Response:
"""Rotates the calendar's ical slug of a view."""
view = action_type_registry.get_by_type(RotateCalendarIcalSlugActionType).do(
request.user,
ViewHandler().get_view_for_update(request.user, view_id).specific,
)
serializer = view_type_registry.get_serializer(
view, ViewSerializer, context={"user": request.user}
)
return Response(serializer.data)

View file

@ -45,6 +45,7 @@ class BaserowPremiumConfig(AppConfig):
from .export.exporter_types import JSONTableExporter, XMLTableExporter
from .plugins import PremiumPlugin
from .views.actions import RotateCalendarIcalSlugActionType
from .views.decorator_types import (
BackgroundColorDecoratorType,
LeftBorderColorDecoratorType,
@ -97,6 +98,7 @@ class BaserowPremiumConfig(AppConfig):
action_type_registry.register(CreateRowCommentActionType())
action_type_registry.register(DeleteRowCommentActionType())
action_type_registry.register(UpdateRowCommentActionType())
action_type_registry.register(RotateCalendarIcalSlugActionType())
from .row_comments.operations import (
CreateRowCommentsOperationType,

View file

@ -0,0 +1,238 @@
import collections
import typing
from datetime import date, datetime
from functools import partial
from operator import attrgetter
from urllib.parse import urljoin
from zoneinfo import ZoneInfo
from django.conf import settings
from django.db.models import QuerySet
from django.utils.timezone import utc
from baserow_premium.views.exceptions import CalendarViewHasNoDateField
from baserow_premium.views.models import CalendarView
from icalendar import Calendar, Event
from baserow.contrib.database.fields.models import Field
from baserow.core.db import specific_queryset
# required by https://icalendar.org/iCalendar-RFC-5545/3-7-3-product-identifier.html
ICAL_PROD_ID = "Baserow / baserow.io"
# required by https://icalendar.org/iCalendar-RFC-5545/3-7-4-version.html
ICAL_VERSION = "2.0"
def row_url_maker(view: CalendarView) -> typing.Callable[[str], str]:
"""
Builds row url maker function.
:param view:
:return:
"""
view_id = view.id
table_id = view.table_id
database_id = view.table.database_id
def url_maker(row_id: str) -> str:
"""
Generate view-specific frontend url for a given row id.
:return: Full frontend url
"""
return urljoin(
settings.PUBLIC_WEB_FRONTEND_URL,
f"/database/{database_id}" f"/table/{table_id}/{view_id}" f"/row/{row_id}",
)
return url_maker
def description_maker(
fields: list[typing.Callable],
) -> typing.Callable[[collections.abc.Iterable], str]:
"""
Builds a helper function to build description from visible fields in a view.
:param fields: Each element of `fields` is a callable that should do two things:
* retrieve specific field's value
* make human-friendly value out of it. Internally, we use
`FieldType.get_human_readable_value` here to render such representation.
:returns: a callable to be used on a row with fields corresponding to ones
expected in `fields` list.
"""
def convert(row: typing.Any) -> typing.Generator[str, None, None]:
"""
Iterates over row's fields and yields stringified values
"""
for converter in fields:
out = converter(row)
yield out
def make_descriptions(row: typing.Any) -> str:
"""
Build an event description from a row
"""
desc = [v for v in convert(row)]
return " - ".join(desc)
return make_descriptions
def make_dtstamp(
in_dtstamp: datetime | date, target_tz: ZoneInfo | None = None
) -> date | datetime:
"""
helper function to make tz-aware datetime object in user-specified timezone.
If in_dtstamp is a date, it will be returned as-is.
"""
if not isinstance(in_dtstamp, date):
raise TypeError(f"Invalid date/time type: {type(in_dtstamp).__name__}")
if not isinstance(in_dtstamp, datetime):
return in_dtstamp
if target_tz:
return in_dtstamp.astimezone(target_tz)
return in_dtstamp
def make_field_renderer(
getter: typing.Callable, renderer: typing.Callable
) -> typing.Callable[[typing.Any], str]:
"""
Chains together functions to get a field value and render it to readable form.
Used by description_maker call.
"""
def _call(row: typing.Any) -> str:
return renderer(getter(row))
return _call
def build_calendar(
qs: QuerySet,
view: CalendarView,
user_timezone: str = "UTC",
limit: int | None = None,
) -> Calendar:
"""
Build Calendar object from a CalendarView-related QuerySet.
:param qs: QuerySet's model must match view's model. QuerySet should not have any
sorting/limiting applied, because it will be processed in this function. Events will
be sorted by a CalendarView's selected date field.
:param view: provides information about selected date field and visible fields in
selected order for this view, which are used to build description string.
:param user_timezone: is an optional timezone name to set on timestamps. It will be
used if CalendarView's date field was created as a datetime field. It won't be
used if date field is a date.
:param limit: when provided, tells how many events should be returned in ICalendar
feed.
"""
date_field = view.date_field
if not date_field or not date_field.specific:
raise CalendarViewHasNoDateField()
date_field_name = date_field.db_column
dget: typing.Callable[[typing.Any], datetime | date] = attrgetter(date_field_name)
# we skip CalendarViewType.get_visible_field_options_in_order to retrieve fields in
# more optimized way, but still requires n queries depending on number of different
# field types related - each field type will have own prefetch related call.
fields = specific_queryset(
Field.objects.filter(
calendarviewfieldoptions__calendar_view_id=view.id,
calendarviewfieldoptions__hidden=False,
)
.order_by("calendarviewfieldoptions__order", "id")
.select_related("content_type")
)
field_type_registry = Field.get_type_registry()
visible_fields: list[typing.Callable] = []
# we're reaching for updated_on and date field even if they're not visible
# when creating vevents for Calendar
query_field_names = {date_field_name, "updated_on"}
for field in fields:
field = field.specific
field_class = field.specific_class
field_type = field_type_registry.get_for_class(field_class)
field_name = field.db_column
field_value_getter = attrgetter(field_name)
# internally converter uses ViewType.get_human_readable_value interface which
# requires a context object in field_object. We don't use this object outside
# this call, so we'll pack it to a standalone callable which will build a
# string from a field value
field_value_converter = partial(
field_type.get_human_readable_value,
field_object={"type": field_type, "name": field_name, "field": field},
)
field_value_renderer = make_field_renderer(
field_value_getter,
field_value_converter,
)
visible_fields.append(field_value_renderer)
query_field_names.add(field_name)
filter_qs = {f"{date_field_name}__isnull": False}
qs = qs.filter(**filter_qs).only(*query_field_names)
if limit:
qs = qs[:limit]
target_timezone_info = ZoneInfo(user_timezone) if user_timezone else utc
if getattr(date_field, "date_include_time", False) and (
field_timezone := getattr(date_field, "date_force_timezone", None)
):
target_timezone_info = ZoneInfo(field_timezone)
make_description = description_maker(visible_fields)
ical = Calendar()
# prodid and version are constant and required by ical spec
ical.add("prodid", ICAL_PROD_ID)
ical.add("version", ICAL_VERSION)
# X-WR-CALNAME:test
# X-WR-TIMEZONE:Europe/Warsaw
ical.add("x-wr-calname", view.name)
ical.add("name", view.name)
ical.add("x-wr-timezone", target_timezone_info)
url_maker = row_url_maker(view)
for row in qs:
dstart = dget(row)
description = make_description(row)
evt = Event()
row_url = url_maker(row.id)
modified_at = make_dtstamp(row.updated_on, target_timezone_info)
# uid required to identify the event.
# Some calendar apps, like Google Calendar require uid to be not a simple value.
# uid 1 won't work, but row url will.
evt.add("uid", row_url)
# row modification will tell if the event was modified since last read
evt.add("dtstamp", modified_at)
evt.add("last-modified", modified_at)
# event start
evt.add("dtstart", make_dtstamp(dstart, target_timezone_info))
# event summary and description
evt.add("summary", description)
evt.add("description", description)
evt.add("location", row_url)
ical.add_component(evt)
return ical

View file

@ -0,0 +1,32 @@
# Generated by Django 4.1.13 on 2024-06-14 11:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("baserow_premium", "0018_aifield_ai_file_field"),
]
operations = [
migrations.AddField(
model_name="calendarview",
name="ical_public",
field=models.BooleanField(
db_index=True,
default=False,
null=True,
help_text="Setting this to `True` will expose ical feed url",
),
),
migrations.AddField(
model_name="calendarview",
name="ical_slug",
field=models.SlugField(
default=None,
help_text="Additional slug that allow access to ical format feed",
null=True,
unique=True,
),
),
]

View file

@ -0,0 +1,99 @@
import dataclasses
from django.contrib.auth.models import AbstractUser
from django.utils.translation import gettext_lazy as _
from baserow_premium.views.models import CalendarView
from baserow.contrib.database.action.scopes import (
VIEW_ACTION_CONTEXT,
ViewActionScopeType,
)
from baserow.contrib.database.views.handler import ViewHandler
from baserow.contrib.database.views.models import View
from baserow.core.action.models import Action
from baserow.core.action.registries import (
ActionScopeStr,
ActionTypeDescription,
UndoableActionType,
)
ICAL_SLUG_FIELD = "ical_slug"
class RotateCalendarIcalSlugActionType(UndoableActionType):
type = "rotate_calendar_ical_view_slug"
description = ActionTypeDescription(
_("Calendar View ICal feed slug URL updated"),
_("View changed public ICal feed slug URL"),
VIEW_ACTION_CONTEXT,
)
analytics_params = [
"view_id",
"table_id",
"database_id",
]
@dataclasses.dataclass
class Params:
view_id: int
view_name: str
table_id: int
table_name: str
database_id: int
database_name: str
slug: str
original_slug: str
@classmethod
def do(cls, user: AbstractUser, view: CalendarView) -> View:
"""
Change the ical feed slug for the current view.
See baserow.contrib.database.views.handler.ViewHandler.rotate_slug for more.
Undoing this action restores the original slug.
Redoing this action updates the slug to the new one.
:param user: The user rotating the slug
:param view: The view of the slug to update.
"""
original_slug = view.ical_slug
ViewHandler().rotate_view_slug(user, view, slug_field=ICAL_SLUG_FIELD)
cls.register_action(
user=user,
params=cls.Params(
view.id,
view.name,
view.table.id,
view.table.name,
view.table.database.id,
view.table.database.name,
view.ical_slug,
original_slug,
),
scope=cls.scope(view.id),
workspace=view.table.database.workspace,
)
return view
@classmethod
def scope(cls, view_id: int) -> ActionScopeStr:
return ViewActionScopeType.value(view_id)
@classmethod
def undo(cls, user: AbstractUser, params: Params, action_to_undo: Action):
view_handler = ViewHandler()
view = view_handler.get_view_for_update(user, params.view_id).specific
view_handler.update_view_slug(
user, view, params.original_slug, slug_field=ICAL_SLUG_FIELD
)
@classmethod
def redo(cls, user: AbstractUser, params: Params, action_to_redo: Action):
view_handler = ViewHandler()
view = view_handler.get_view_for_update(user, params.view_id).specific
view_handler.update_view_slug(
user, view, params.slug, slug_field=ICAL_SLUG_FIELD
)

View file

@ -1,5 +1,7 @@
from django.conf import settings
from django.db import models
from django.db.models import Q
from django.urls import reverse_lazy
from baserow.contrib.database.fields.models import Field, FileField, SingleSelectField
from baserow.contrib.database.views.models import View
@ -81,10 +83,34 @@ class CalendarView(View):
help_text="One of the supported date fields that "
"the calendar view will be based on.",
)
# TODO Remove null=True in a future release
ical_public = models.BooleanField(
null=True,
default=False,
db_index=True,
help_text=("Setting this to `True` will expose ical feed url"),
)
# TODO Remove null=True in a future release
ical_slug = models.SlugField(
null=True,
unique=True,
default=None,
db_index=True,
help_text=("Additional slug that allow access to ical format feed"),
)
class Meta:
db_table = "database_calendarview"
@property
def ical_feed_url(self) -> str | None:
if self.ical_slug:
url_name = "api:database:views:calendar:calendar_ical_feed"
return (
f"{settings.PUBLIC_BACKEND_URL}"
f"{reverse_lazy(url_name, args=(self.ical_slug,))}"
)
class CalendarViewFieldOptionsManager(models.Manager):
"""

View file

@ -1,6 +1,7 @@
from typing import Any, Dict, List, Optional, Set
from zipfile import ZipFile
from django.contrib.auth.models import AbstractUser
from django.core.files.storage import Storage
from django.db.models import Q
from django.urls import include, path
@ -14,6 +15,7 @@ from baserow_premium.api.views.kanban.errors import (
from baserow_premium.api.views.kanban.serializers import (
KanbanViewFieldOptionsSerializer,
)
from rest_framework.fields import BooleanField, CharField
from rest_framework.serializers import PrimaryKeyRelatedField
from baserow.contrib.database.api.fields.errors import (
@ -273,9 +275,20 @@ class CalendarViewType(ViewType):
model_class = CalendarView
field_options_model_class = CalendarViewFieldOptions
field_options_serializer_class = CalendarViewFieldOptionsSerializer
allowed_fields = ["date_field"]
allowed_fields = [
"date_field",
"ical_public",
]
field_options_allowed_fields = ["hidden", "order"]
serializer_field_names = ["date_field"]
# We don't expose CalendarView.ical_slug directly only through ical_feed_url
# property. When the user wants to change ical feed publicity status, the frontend
# needs to send ical_public flag only.
serializer_field_names = [
"date_field",
"ical_feed_url",
"ical_public",
]
serializer_field_overrides = {
"date_field": PrimaryKeyRelatedField(
queryset=Field.objects.all(),
@ -283,6 +296,19 @@ class CalendarViewType(ViewType):
default=None,
allow_null=True,
),
"ical_feed_url": CharField(
read_only=True,
help_text="Read-only field with ICal feed endpoint. "
"Note: this url will not be active if "
"ical_public value is set to False.",
),
"ical_public": BooleanField(
allow_null=True,
default=None,
help_text="A flag to show if ical feed is exposed. "
"Set this field to True when modifying "
"this resource to enable ICal feed url.",
),
}
api_exceptions_map = {
IncompatibleField: ERROR_INCOMPATIBLE_FIELD,
@ -299,6 +325,14 @@ class CalendarViewType(ViewType):
path("calendar/", include(api_urls, namespace=self.type)),
]
def before_view_create(self, values: dict, table: "Table", user: "AbstractUser"):
if values.get("ical_public"):
values["ical_slug"] = View.create_new_slug()
def before_view_update(self, values: dict, table: "Table", user: "AbstractUser"):
if values.get("ical_public") and not table.ical_slug:
table.ical_slug = View.create_new_slug()
def prepare_values(self, values, table, user):
"""
Check if the provided date field belongs to the same table.

View file

@ -1,4 +1,6 @@
from datetime import timezone
from datetime import datetime, timedelta, timezone
from functools import partial
from unittest import mock
from urllib.parse import urlencode
from zoneinfo import ZoneInfo
@ -10,6 +12,8 @@ from django.utils import timezone as django_timezone
import pytest
from baserow_premium.views.models import CalendarView, CalendarViewFieldOptions
from freezegun import freeze_time
from icalendar import Calendar
from rest_framework.response import Response
from rest_framework.status import (
HTTP_200_OK,
HTTP_400_BAD_REQUEST,
@ -275,13 +279,11 @@ def test_list_all_rows(api_client, premium_data_fixture):
]
model = table.get_model()
for datetime in datetimes:
for dt in datetimes:
model.objects.create(
**{
f"field_{date_field.id}": (
datetime.replace(tzinfo=timezone.utc)
if datetime is not None
else None
dt.replace(tzinfo=timezone.utc) if dt is not None else None
),
}
)
@ -362,13 +364,11 @@ def test_list_all_rows_limit_offset(api_client, premium_data_fixture):
]
model = table.get_model()
for datetime in datetimes:
for dt in datetimes:
for i in range(5):
model.objects.create(
**{
f"field_{date_field.id}": datetime.replace(
hour=i, tzinfo=timezone.utc
),
f"field_{date_field.id}": dt.replace(hour=i, tzinfo=timezone.utc),
}
)
@ -951,6 +951,10 @@ def test_get_public_calendar_view_with_single_select_and_cover(
)
response_json = response.json()
assert response.status_code == HTTP_200_OK, response_json
assert calendar_view.ical_public is False
assert calendar_view.ical_slug is None
assert calendar_view.ical_feed_url is None
assert response_json == {
"fields": [
{
@ -995,6 +999,8 @@ def test_get_public_calendar_view_with_single_select_and_cover(
"type": "calendar",
"date_field": date_field.id,
"show_logo": True,
"ical_public": False,
"ical_feed_url": calendar_view.ical_feed_url,
},
}
@ -1276,3 +1282,442 @@ def test_search_calendar_with_empty_term(api_client, premium_data_fixture):
)
assert total_results == 5
@pytest.mark.django_db
@pytest.mark.view_calendar
def test_calendar_view_ical_feed_sharing(
premium_data_fixture, api_client, data_fixture
):
"""
Test ical feed sharing
"""
workspace = premium_data_fixture.create_workspace(name="Workspace 1")
user, token = premium_data_fixture.create_user_and_token(workspace=workspace)
regular_user = data_fixture.create_user()
regular_token = data_fixture.generate_token(user=regular_user)
table = premium_data_fixture.create_database_table(user=user)
date_field = premium_data_fixture.create_date_field(table=table)
view_handler = ViewHandler()
calendar_view: CalendarView = view_handler.create_view(
user=user,
table=table,
type_name="calendar",
date_field=date_field,
)
assert not calendar_view.ical_public
assert not calendar_view.ical_feed_url
req_post = partial(
api_client.post, format="json", HTTP_AUTHORIZATION=f"JWT {token}"
)
req_patch = partial(
api_client.patch, format="json", HTTP_AUTHORIZATION=f"JWT {token}"
)
# step 1: make ical feed public
resp: Response = req_patch(
reverse("api:database:views:item", kwargs={"view_id": calendar_view.id}),
{"ical_public": True},
)
assert resp.status_code == HTTP_200_OK
calendar_view.refresh_from_db()
assert calendar_view.ical_public
assert calendar_view.ical_feed_url
assert calendar_view.ical_slug
assert resp.data.get("ical_feed_url") == calendar_view.ical_feed_url
assert resp.data.get("ical_public")
# step 2: disable the view
resp: Response = req_patch(
reverse("api:database:views:item", kwargs={"view_id": calendar_view.id}),
{"ical_public": False},
)
assert resp.status_code == HTTP_200_OK
calendar_view.refresh_from_db()
# slug stays the same
assert calendar_view.ical_slug
assert calendar_view.ical_feed_url
assert calendar_view.ical_public is False
assert resp.data.get("ical_feed_url") == calendar_view.ical_feed_url
assert resp.data.get("ical_public") is False
# step 3: reenable ical feed
resp: Response = req_patch(
reverse("api:database:views:item", kwargs={"view_id": calendar_view.id}),
{"ical_public": True},
)
assert resp.status_code == HTTP_200_OK
calendar_view.refresh_from_db()
assert calendar_view.ical_public
assert calendar_view.ical_feed_url
feed_url = calendar_view.ical_feed_url
# step 4: rotate ical slug
resp: Response = req_post(
reverse(
"api:database:views:calendar:ical_slug_rotate",
kwargs={"view_id": calendar_view.id},
),
{},
)
calendar_view.refresh_from_db()
assert calendar_view.ical_public
assert calendar_view.ical_feed_url
assert calendar_view.ical_feed_url != feed_url
feed_url = calendar_view.ical_feed_url
assert resp.data.get("ical_feed_url") == calendar_view.ical_feed_url
@pytest.mark.django_db
@pytest.mark.view_calendar
def test_calendar_view_ical_feed_contents(
premium_data_fixture, api_client, data_fixture
):
"""
Basic ical feed functionality test
"""
workspace = premium_data_fixture.create_workspace(name="Workspace 1")
user, token = premium_data_fixture.create_user_and_token(workspace=workspace)
table = premium_data_fixture.create_database_table(user=user)
field_title = data_fixture.create_text_field(table=table, user=user)
field_description = data_fixture.create_text_field(table=table, user=user)
field_extra = data_fixture.create_text_field(table=table, user=user)
field_num = data_fixture.create_number_field(table=table, user=user)
date_field = premium_data_fixture.create_date_field(table=table)
all_fields = [field_title, field_description, field_extra, field_num, date_field]
view_handler = ViewHandler()
calendar_view: CalendarView = view_handler.create_view(
user=user,
table=table,
type_name="calendar",
date_field=date_field,
)
tmodel = table.get_model()
NUM_EVENTS = 20
def make_values(num):
curr = 0
field_names = [f"field_{f.id}" for f in all_fields]
start = datetime.now()
while curr < num:
values = [
f"title {curr}",
f"description {curr}",
f"extra {curr}",
curr,
start + timedelta(days=1, hours=curr),
]
curr += 1
row = dict(zip(field_names, values))
tmodel.objects.create(**row)
make_values(NUM_EVENTS)
assert not calendar_view.ical_public
assert not calendar_view.ical_feed_url
req_get = partial(api_client.get, format="json", HTTP_AUTHORIZATION=f"JWT {token}")
req_patch = partial(
api_client.patch, format="json", HTTP_AUTHORIZATION=f"JWT {token}"
)
# make ical feed public
resp: Response = req_patch(
reverse("api:database:views:item", kwargs={"view_id": calendar_view.id}),
{"ical_public": True},
)
assert resp.status_code == HTTP_200_OK
calendar_view.refresh_from_db()
assert calendar_view.ical_public
assert calendar_view.ical_feed_url
assert calendar_view.ical_slug
assert resp.data.get("ical_feed_url") == calendar_view.ical_feed_url
visible_a = {f.id: {"hidden": True} for f in all_fields}
visible_a.update(
{
field_num.id: {"hidden": False, "order": 1},
field_extra.id: {"hidden": False, "order": 2},
}
)
resp = req_patch(
reverse(
"api:database:views:field_options", kwargs={"view_id": calendar_view.id}
),
{"field_options": visible_a},
)
assert resp.status_code == HTTP_200_OK, resp.content
resp = req_get(
reverse(
"api:database:views:calendar:calendar_ical_feed",
kwargs={"ical_slug": calendar_view.ical_slug},
)
)
assert resp.status_code == HTTP_200_OK
assert resp.headers.get("content-type") == "text/calendar", resp.headers
assert resp.content
# parse generated feed
feed = Calendar.from_ical(resp.content)
assert feed
assert feed.get("PRODID") == "Baserow / baserow.io"
evsummary = [ev.get("description") for ev in feed.walk("VEVENT")]
assert len(evsummary) == NUM_EVENTS
assert evsummary[0] == "0 - extra 0"
assert evsummary[1] == "1 - extra 1"
visible_a.update(
{
field_num.id: {"hidden": False, "order": 2},
field_extra.id: {"hidden": False, "order": 1},
}
)
resp = req_patch(
reverse(
"api:database:views:field_options", kwargs={"view_id": calendar_view.id}
),
{"field_options": visible_a},
)
assert resp.status_code == HTTP_200_OK, resp.content
resp = req_get(
reverse(
"api:database:views:calendar:calendar_ical_feed",
kwargs={"ical_slug": calendar_view.ical_slug},
)
)
assert resp.status_code == HTTP_200_OK
assert resp.headers.get("content-type") == "text/calendar", resp.headers
assert resp.content
# parse generated feed
feed = Calendar.from_ical(resp.content)
assert feed
assert feed.get("PRODID") == "Baserow / baserow.io"
evsummary = [ev.get("description") for ev in feed.walk("VEVENT")]
assert len(evsummary) == NUM_EVENTS
assert evsummary[1] == "extra 1 - 1"
with override_settings(BASEROW_ICAL_VIEW_MAX_EVENTS=5):
resp = req_get(
reverse(
"api:database:views:calendar:calendar_ical_feed",
kwargs={"ical_slug": calendar_view.ical_slug},
)
)
assert resp.status_code == HTTP_200_OK
assert resp.headers.get("content-type") == "text/calendar", resp.headers
assert resp.content
# parse generated feed
feed = Calendar.from_ical(resp.content)
assert feed
assert feed.get("PRODID") == "Baserow / baserow.io"
evsummary = [ev.get("description") for ev in feed.walk("VEVENT")]
assert len(evsummary) == 5
@pytest.mark.django_db
@pytest.mark.view_calendar
def test_calendar_view_ical_feed_invalid_references(
premium_data_fixture, api_client, data_fixture
):
"""
Test if calendar is not accessible using invalid reference values
"""
workspace = premium_data_fixture.create_workspace(name="Workspace 1")
user, token = premium_data_fixture.create_user_and_token(workspace=workspace)
regular_user = data_fixture.create_user()
regular_token = data_fixture.generate_token(regular_user)
table = premium_data_fixture.create_database_table(user=user)
date_field = premium_data_fixture.create_date_field(table=table)
view_handler = ViewHandler()
calendar_view: CalendarView = view_handler.create_view(
user=user,
table=table,
type_name="calendar",
date_field=date_field,
ical_public=False,
ical_slug=View.create_new_slug(),
)
req_get = partial(api_client.get, format="json", HTTP_AUTHORIZATION=f"JWT {token}")
req_patch = partial(
api_client.patch, format="json", HTTP_AUTHORIZATION=f"JWT {token}"
)
req_post = partial(
api_client.post, format="json", HTTP_AUTHORIZATION=f"JWT {token}"
)
resp = req_get(
reverse(
"api:database:views:calendar:calendar_ical_feed",
kwargs={"ical_slug": calendar_view.ical_slug},
)
)
assert resp.status_code == HTTP_404_NOT_FOUND
# make ical feed public
resp: Response = req_patch(
reverse("api:database:views:item", kwargs={"view_id": calendar_view.id}),
{"ical_public": True},
)
assert resp.status_code == HTTP_200_OK
assert resp.data["ical_feed_url"]
assert resp.data["ical_public"]
calendar_view.refresh_from_db()
assert calendar_view.ical_public
assert calendar_view.ical_feed_url
assert calendar_view.ical_slug
assert resp.data.get("ical_feed_url") == calendar_view.ical_feed_url
feed_url = calendar_view.ical_feed_url
# check feed url availability
resp = req_get(feed_url)
assert resp.status_code == HTTP_200_OK
assert resp.headers["Content-Type"] == "text/calendar"
# few possible invalid urls
resp = req_get(
reverse(
"api:database:views:calendar:calendar_ical_feed",
kwargs={"ical_slug": "phony"},
)
)
assert resp.status_code == HTTP_404_NOT_FOUND
resp = req_get(
reverse(
"api:database:views:calendar:calendar_ical_feed",
kwargs={"ical_slug": calendar_view.id},
)
)
assert resp.status_code == HTTP_404_NOT_FOUND
resp = req_get(
reverse(
"api:database:views:calendar:calendar_ical_feed",
kwargs={"ical_slug": calendar_view.slug},
)
)
assert resp.status_code == HTTP_404_NOT_FOUND
# rather won't happen, but a view can be non-shareable
with mock.patch(
"baserow_premium.views.view_types.CalendarViewType.can_share", False
):
resp: Response = req_post(
reverse(
"api:database:views:calendar:ical_slug_rotate",
kwargs={"view_id": calendar_view.id},
),
{},
)
assert resp.status_code == HTTP_400_BAD_REQUEST
assert resp.data["error"] == "ERROR_CANNOT_SHARE_VIEW_TYPE"
# sanity check - the view should remain
calendar_view.refresh_from_db()
assert calendar_view.ical_public
assert calendar_view.ical_feed_url
assert calendar_view.ical_feed_url == feed_url
# invalid user check
resp: Response = req_post(
reverse(
"api:database:views:calendar:ical_slug_rotate",
kwargs={"view_id": calendar_view.id},
),
{},
HTTP_AUTHORIZATION=f"JWT {regular_token}",
)
assert resp.status_code == HTTP_400_BAD_REQUEST
assert resp.data["error"] == "ERROR_USER_NOT_IN_GROUP"
@pytest.mark.django_db
@pytest.mark.view_calendar
def test_calendar_view_ical_no_date_field(
premium_data_fixture, api_client, data_fixture
):
"""
Test if calendar is not accessible using invalid reference values
"""
workspace = premium_data_fixture.create_workspace(name="Workspace 1")
user, token = premium_data_fixture.create_user_and_token(workspace=workspace)
regular_user = data_fixture.create_user()
regular_token = data_fixture.generate_token(regular_user)
table = premium_data_fixture.create_database_table(user=user)
date_field = premium_data_fixture.create_date_field(table=table)
view_handler = ViewHandler()
calendar_view: CalendarView = view_handler.create_view(
user=user,
table=table,
type_name="calendar",
ical_public=False,
ical_slug=View.create_new_slug(),
)
assert not calendar_view.date_field
req_get = partial(api_client.get, format="json", HTTP_AUTHORIZATION=f"JWT {token}")
req_patch = partial(
api_client.patch, format="json", HTTP_AUTHORIZATION=f"JWT {token}"
)
req_post = partial(
api_client.post, format="json", HTTP_AUTHORIZATION=f"JWT {token}"
)
resp = req_get(
reverse(
"api:database:views:calendar:calendar_ical_feed",
kwargs={"ical_slug": calendar_view.ical_slug},
)
)
assert resp.status_code == HTTP_404_NOT_FOUND
# make ical feed public
resp: Response = req_patch(
reverse("api:database:views:item", kwargs={"view_id": calendar_view.id}),
{"ical_public": True},
)
assert resp.status_code == HTTP_200_OK
assert resp.data["ical_feed_url"]
assert resp.data["ical_public"]
calendar_view.refresh_from_db()
assert calendar_view.ical_public
assert calendar_view.ical_feed_url
assert calendar_view.ical_slug
assert resp.data.get("ical_feed_url") == calendar_view.ical_feed_url
feed_url = calendar_view.ical_feed_url
# check feed url availability
resp = req_get(feed_url)
assert resp.status_code == HTTP_400_BAD_REQUEST
assert resp.data["error"] == "ERROR_CALENDAR_VIEW_HAS_NO_DATE_FIELD"

View file

@ -0,0 +1,278 @@
from datetime import date, datetime, timedelta
from operator import attrgetter
from zoneinfo import ZoneInfo
from django.utils.timezone import override, utc
import pytest
from baserow_premium.ical_utils import (
build_calendar,
description_maker,
make_dtstamp,
make_field_renderer,
)
from baserow_premium.views.models import CalendarView
from baserow.contrib.database.views.handler import ViewHandler
NUM_EVENTS = 20
BASEROW_ICAL_VIEW_MAX_EVENTS = 5
def test_ical_generation_with_datetime(
db, premium_data_fixture, api_client, data_fixture
):
"""
Test ical feed with calendar with datetime field
"""
workspace = premium_data_fixture.create_workspace(name="Workspace 1")
user, token = premium_data_fixture.create_user_and_token(workspace=workspace)
table = premium_data_fixture.create_database_table(user=user)
field_title = data_fixture.create_text_field(table=table, user=user)
field_description = data_fixture.create_long_text_field(table=table, user=user)
field_extra = data_fixture.create_text_field(table=table, user=user)
field_num = data_fixture.create_number_field(table=table, user=user)
date_field = premium_data_fixture.create_date_field(
table=table,
date_include_time=True,
)
all_fields = [field_title, field_description, field_extra, field_num, date_field]
view_handler = ViewHandler()
calendar_view: CalendarView = view_handler.create_view(
user=user,
table=table,
type_name="calendar",
date_field=date_field,
)
tmodel = table.get_model()
amsterdam = ZoneInfo("Europe/Amsterdam")
start = datetime.now(tz=amsterdam).replace(microsecond=0)
def make_values(num):
curr = 0
field_names = [f"field_{f.id}" for f in all_fields]
while curr < num:
values = [
f"title {curr}",
f"description {curr}",
f"extra {curr}",
curr,
start + timedelta(days=1, hours=curr),
]
curr += 1
row = dict(zip(field_names, values))
tmodel.objects.create(**row)
make_values(NUM_EVENTS)
assert not calendar_view.ical_public
assert not calendar_view.ical_feed_url
cal = build_calendar(tmodel.objects.all(), calendar_view)
events = cal.walk("VEVENT")
assert len(events)
for idx, evt in enumerate(events):
elm = evt.get("DTSTART")
# elm.dt is a date
assert isinstance(elm.dt, datetime)
assert elm.dt.astimezone(utc) == (
start + timedelta(days=1, hours=idx)
).astimezone(utc)
def test_ical_generation_with_datetime_with_tz(
db, premium_data_fixture, api_client, data_fixture
):
"""
Test ical feed with calendar with datetime field
"""
workspace = premium_data_fixture.create_workspace(name="Workspace 1")
user, token = premium_data_fixture.create_user_and_token(workspace=workspace)
table = premium_data_fixture.create_database_table(user=user)
field_title = data_fixture.create_text_field(table=table, user=user)
field_description = data_fixture.create_text_field(table=table, user=user)
field_extra = data_fixture.create_text_field(table=table, user=user)
field_num = data_fixture.create_number_field(table=table, user=user)
date_field = premium_data_fixture.create_date_field(
table=table, date_include_time=True, date_force_timezone="Australia/Canberra"
)
all_fields = [field_title, field_description, field_extra, field_num, date_field]
view_handler = ViewHandler()
calendar_view: CalendarView = view_handler.create_view(
user=user,
table=table,
type_name="calendar",
date_field=date_field,
)
tmodel = table.get_model()
amsterdam = ZoneInfo("Europe/Amsterdam")
start = datetime.now(tz=amsterdam).replace(microsecond=0)
def make_values(num):
curr = 0
field_names = [f"field_{f.id}" for f in all_fields]
while curr < num:
values = [
f"title {curr}",
f"description {curr}",
f"extra {curr}",
curr,
start + timedelta(days=1, hours=curr),
]
curr += 1
row = dict(zip(field_names, values))
tmodel.objects.create(**row)
make_values(NUM_EVENTS)
assert not calendar_view.ical_public
assert not calendar_view.ical_feed_url
cal = build_calendar(tmodel.objects.all(), calendar_view)
events = cal.walk("VEVENT")
assert len(events)
for idx, evt in enumerate(events):
elm = evt.get("DTSTART")
# elm.dt is a date
assert isinstance(elm.dt, datetime)
assert elm.dt.astimezone(utc) == (
start + timedelta(days=1, hours=idx)
).astimezone(utc)
def test_ical_generation_with_date(db, premium_data_fixture, api_client, data_fixture):
"""
Test ical feed with calendar with datetime field
"""
workspace = premium_data_fixture.create_workspace(name="Workspace 1")
user, token = premium_data_fixture.create_user_and_token(workspace=workspace)
table = premium_data_fixture.create_database_table(user=user)
field_title = data_fixture.create_text_field(table=table, user=user)
field_description = data_fixture.create_text_field(table=table, user=user)
field_extra = data_fixture.create_text_field(table=table, user=user)
field_num = data_fixture.create_number_field(table=table, user=user)
date_field = premium_data_fixture.create_date_field(
table=table,
date_include_time=False,
)
all_fields = [field_title, field_description, field_extra, field_num, date_field]
view_handler = ViewHandler()
calendar_view: CalendarView = view_handler.create_view(
user=user,
table=table,
type_name="calendar",
date_field=date_field,
)
tmodel = table.get_model()
amsterdam = ZoneInfo("Europe/Amsterdam")
start = date.today()
def make_values(num):
curr = 0
field_names = [f"field_{f.id}" for f in all_fields]
while curr < num:
values = [
f"title {curr}",
f"description {curr}",
f"extra {curr}",
curr,
start + timedelta(days=curr + 1),
]
curr += 1
row = dict(zip(field_names, values))
tmodel.objects.create(**row)
make_values(NUM_EVENTS)
assert not calendar_view.ical_public
assert not calendar_view.ical_feed_url
cal = build_calendar(tmodel.objects.all(), calendar_view)
events = cal.walk("VEVENT")
assert len(events)
for idx, evt in enumerate(events):
elm = evt.get("DTSTART")
# elm.dt is a date
assert isinstance(elm.dt, date)
assert not isinstance(elm.dt, datetime)
assert elm.dt == start + timedelta(days=1 + idx)
@pytest.mark.parametrize(
"current_timezone,test_input,expected_output,raises",
[
(
"UTC",
(datetime(year=2020, month=1, day=1),),
datetime(year=2020, month=1, day=1),
None,
),
(
"UTC",
(datetime(year=2020, month=1, day=1), ZoneInfo("CET")),
datetime(year=2020, month=1, day=1, hour=1, tzinfo=ZoneInfo("CET")),
None,
),
(
"CET",
(date(year=2020, month=1, day=1), "UTC"),
date(year=2020, month=1, day=1),
None,
),
],
)
def test_make_dtstamp(current_timezone, test_input, expected_output, raises):
"""
Test make_dtstamp util function.
"""
with override(current_timezone):
output = make_dtstamp(*test_input)
assert output == expected_output
def test_make_dtstamp_invalid_inputs():
"""
Test make_dtstamp util function invalid inputs.
"""
with pytest.raises(TypeError):
output = make_dtstamp("2020-01-01")
with pytest.raises(TypeError):
output = make_dtstamp(None)
with pytest.raises(TypeError):
output = make_dtstamp(1)
def test_description_maker():
fields = [
make_field_renderer(attrgetter("foo"), str),
make_field_renderer(attrgetter("bar"), str),
make_field_renderer(attrgetter("foo"), str),
]
row_description = description_maker(fields)
class R:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
with pytest.raises(AttributeError):
row_description(R())
assert row_description(R(foo="abc", bar=None)) == "abc - None - abc"

View file

@ -1,24 +1,32 @@
from datetime import date, datetime, timezone
from datetime import date, datetime, timedelta, timezone
from functools import partial
from io import BytesIO
from urllib.parse import urlparse
from zipfile import ZIP_DEFLATED, ZipFile
from zoneinfo import ZoneInfo
from django.core.files.storage import FileSystemStorage
from django.urls import reverse
from django.utils import timezone as django_timezone
import pytest
from baserow_premium.ical_utils import build_calendar
from baserow_premium.views.exceptions import CalendarViewHasNoDateField
from baserow_premium.views.handler import (
generate_per_day_intervals,
get_rows_grouped_by_date_field,
to_midnight,
)
from baserow_premium.views.models import CalendarViewFieldOptions
from baserow_premium.views.models import CalendarView, CalendarViewFieldOptions
from icalendar import Calendar
from rest_framework.response import Response
from rest_framework.status import HTTP_200_OK
from baserow.contrib.database.action.scopes import ViewActionScopeType
from baserow.contrib.database.fields.exceptions import FieldNotInTable
from baserow.contrib.database.fields.handler import FieldHandler
from baserow.contrib.database.fields.registries import field_type_registry
from baserow.contrib.database.management.commands.fill_table_rows import fill_table_rows
from baserow.contrib.database.views.actions import UpdateViewActionType
from baserow.contrib.database.views.handler import ViewHandler
from baserow.contrib.database.views.registries import view_type_registry
@ -942,3 +950,308 @@ def test_calendar_view_hierarchy(premium_data_fixture):
calendar_view_field_options = calendar_view.get_field_options()[0]
assert calendar_view_field_options.get_parent() == calendar_view
assert calendar_view_field_options.get_root() == workspace
@pytest.mark.django_db
@pytest.mark.view_calendar
def test_calendar_view_ical_feed_timestamps(
premium_data_fixture, api_client, data_fixture
):
"""
Test ical feed with calendar with datetime field
"""
workspace = premium_data_fixture.create_workspace(name="Workspace 1")
user, token = premium_data_fixture.create_user_and_token(workspace=workspace)
table = premium_data_fixture.create_database_table(user=user)
field_title = data_fixture.create_text_field(table=table, user=user)
field_description = data_fixture.create_text_field(table=table, user=user)
field_extra = data_fixture.create_text_field(table=table, user=user)
field_num = data_fixture.create_number_field(table=table, user=user)
date_field = premium_data_fixture.create_date_field(
table=table,
date_include_time=True,
)
all_fields = [field_title, field_description, field_extra, field_num, date_field]
view_handler = ViewHandler()
calendar_view: CalendarView = view_handler.create_view(
user=user,
table=table,
type_name="calendar",
date_field=date_field,
)
tmodel = table.get_model()
NUM_EVENTS = 5
amsterdam = ZoneInfo("Europe/Amsterdam")
start = datetime.now(tz=amsterdam).replace(microsecond=0)
def make_values(num):
curr = 0
field_names = [f"field_{f.id}" for f in all_fields]
while curr < num:
values = [
f"title {curr}",
f"description {curr}",
f"extra {curr}",
curr,
start + timedelta(days=1, hours=curr),
]
curr += 1
row = dict(zip(field_names, values))
tmodel.objects.create(**row)
make_values(NUM_EVENTS)
assert not calendar_view.ical_public
assert not calendar_view.ical_feed_url
req_get = partial(api_client.get, format="json", HTTP_AUTHORIZATION=f"JWT {token}")
req_patch = partial(
api_client.patch, format="json", HTTP_AUTHORIZATION=f"JWT {token}"
)
# make ical feed public
resp: Response = req_patch(
reverse("api:database:views:item", kwargs={"view_id": calendar_view.id}),
{"ical_public": True},
)
assert resp.status_code == HTTP_200_OK
assert resp.data["ical_feed_url"]
calendar_view.refresh_from_db()
resp = req_get(
reverse(
"api:database:views:calendar:calendar_ical_feed",
kwargs={"ical_slug": calendar_view.ical_slug},
)
)
assert resp.status_code == HTTP_200_OK
assert resp.headers.get("content-type") == "text/calendar", resp.headers
assert resp.content
# parse generated feed
feed = Calendar.from_ical(resp.content)
assert feed
assert feed.get("PRODID") == "Baserow / baserow.io"
evstart = [ev.get("DTSTART") for ev in feed.walk("VEVENT")]
assert len(evstart) == NUM_EVENTS
for idx, elm in enumerate(evstart):
# elm.dt is tz-aware, but tz will be +00:00
assert elm.dt.utcoffset() == timedelta(0), elm.dt.utcoffset()
assert elm.dt - (start + timedelta(days=1, hours=idx)) == timedelta(0)
@pytest.mark.django_db
@pytest.mark.view_calendar
def test_calendar_view_ical_feed_dates(premium_data_fixture, api_client, data_fixture):
"""
Test ical feed with calendar with date field
"""
workspace = premium_data_fixture.create_workspace(name="Workspace 1")
user, token = premium_data_fixture.create_user_and_token(workspace=workspace)
table = premium_data_fixture.create_database_table(user=user)
field_title = data_fixture.create_text_field(table=table, user=user)
field_description = data_fixture.create_text_field(table=table, user=user)
field_extra = data_fixture.create_text_field(table=table, user=user)
field_num = data_fixture.create_number_field(table=table, user=user)
date_field = premium_data_fixture.create_date_field(
table=table,
date_include_time=False,
)
all_fields = [field_title, field_description, field_extra, field_num, date_field]
view_handler = ViewHandler()
calendar_view: CalendarView = view_handler.create_view(
user=user,
table=table,
type_name="calendar",
date_field=date_field,
)
tmodel = table.get_model()
NUM_EVENTS = 5
start = date.today()
def make_values(num):
curr = 0
field_names = [f"field_{f.id}" for f in all_fields]
while curr < num:
values = [
f"title {curr}",
f"description {curr}",
f"extra {curr}",
curr,
start + timedelta(days=1 + curr),
]
curr += 1
row = dict(zip(field_names, values))
tmodel.objects.create(**row)
make_values(NUM_EVENTS)
assert not calendar_view.ical_public
assert not calendar_view.ical_feed_url
req_get = partial(api_client.get, format="json", HTTP_AUTHORIZATION=f"JWT {token}")
req_patch = partial(
api_client.patch, format="json", HTTP_AUTHORIZATION=f"JWT {token}"
)
# make ical feed public
resp: Response = req_patch(
reverse("api:database:views:item", kwargs={"view_id": calendar_view.id}),
{"ical_public": True},
)
assert resp.status_code == HTTP_200_OK
assert resp.data["ical_feed_url"]
calendar_view.refresh_from_db()
resp = req_get(
reverse(
"api:database:views:calendar:calendar_ical_feed",
kwargs={"ical_slug": calendar_view.ical_slug},
)
)
assert resp.status_code == HTTP_200_OK
assert resp.headers.get("content-type") == "text/calendar", resp.headers
assert resp.content
# parse generated feed
feed = Calendar.from_ical(resp.content)
assert feed
assert feed.get("PRODID") == "Baserow / baserow.io"
evstart = [ev.get("DTSTART") for ev in feed.walk("VEVENT")]
assert len(evstart) == NUM_EVENTS
for idx, elm in enumerate(evstart):
# elm.dt is a date
assert isinstance(elm.dt, date)
assert elm.dt == start + timedelta(days=1 + idx)
@pytest.mark.django_db
@pytest.mark.view_calendar
def test_calendar_ical_utils_queries(
premium_data_fixture, data_fixture, django_assert_num_queries
):
"""
Test For ical utils queries issued.
"""
workspace = premium_data_fixture.create_workspace(name="Workspace 1")
user, token = premium_data_fixture.create_user_and_token(workspace=workspace)
table = premium_data_fixture.create_database_table(user=user)
field_title = data_fixture.create_text_field(table=table, user=user)
field_description = data_fixture.create_long_text_field(table=table, user=user)
field_extra = data_fixture.create_rating_field(table=table, user=user)
field_num = data_fixture.create_number_field(table=table, user=user)
field_num_2 = data_fixture.create_number_field(table=table, user=user)
field_num_3 = data_fixture.create_number_field(table=table, user=user)
field_bool = data_fixture.create_boolean_field(table=table, user=user)
field_duration = data_fixture.create_duration_field(table=table, user=user)
field_ai = premium_data_fixture.create_ai_field(table=table, user=user)
date_field = premium_data_fixture.create_date_field(
table=table,
date_include_time=False,
)
all_fields = [
field_title,
field_description,
field_extra,
field_num,
date_field,
field_num_2,
field_num_3,
field_bool,
field_duration,
field_ai,
]
view_handler = ViewHandler()
calendar_view: CalendarView = view_handler.create_view(
user=user,
table=table,
type_name="calendar",
date_field=date_field,
)
tmodel = table.get_model()
NUM_EVENTS = 5
def make_values(num, for_model, for_fields):
fill_table_rows(num, table)
make_values(NUM_EVENTS, tmodel, all_fields)
calendar_view.date_field_id = None
all_items_query = tmodel.objects.all().order_by("id")
with django_assert_num_queries(0):
with pytest.raises(CalendarViewHasNoDateField) as err:
build_calendar(all_items_query, calendar_view)
view_handler.update_field_options(
view=calendar_view,
field_options={f.id: {"hidden": True} for f in all_fields},
)
view_handler.update_field_options(
view=calendar_view,
field_options={
field_title.id: {"hidden": False},
field_num.id: {"hidden": False},
field_duration.id: {"hidden": False},
},
)
calendar_view.refresh_from_db()
assert all_items_query.count() == NUM_EVENTS
# 8 fields in total:
# - 2 for resolve calendar_view.date_field.specific
# - 1 for resolve calendar_view.table
# - 1 for a visible fields list for this calendarview
# - 1 for long text field related fields
# - 1 for numberfield related fields
# - 1 for durationfield related fields
# - 1 for all_items_query
with django_assert_num_queries(8) as ctx:
cal = build_calendar(all_items_query, calendar_view)
assert len(list(cal.walk("VEVENT"))) == len(all_items_query)
for idx, evt in enumerate(cal.walk("VEVENT")):
assert evt.get("uid")
uid_url = urlparse(evt.get("uid"))
assert uid_url.netloc
assert uid_url.path == (
f"/database/{table.database_id}/table/{table.id}/{calendar_view.id}/row/{idx+1}"
)
assert evt.get("summary")
view_handler.update_field_options(
view=calendar_view,
field_options={
field_title.id: {"hidden": False},
field_num.id: {"hidden": False},
field_extra.id: {"hidden": False},
field_description.id: {"hidden": False},
field_duration.id: {"hidden": False},
},
)
calendar_view.refresh_from_db()
# 10 queries:
# - 2 for resolve calendar_view.date_field.specific
# - 1 for resolve calendar_view.table
# - 1 for a visible fields list for this calendarview
# - 1 for long text field related fields
# - 1 for text field
# - 1 for rating field
# - 1 for numberfield related fields
# - 1 for durationfield related fields
# - 1 for all_items_query
with django_assert_num_queries(10) as ctx:
cal = build_calendar(all_items_query.all(), calendar_view)

View file

@ -15,8 +15,8 @@
<span>
{{ $t('shareLinkOptions.baserowLogo.label') }}
</span>
<i v-if="!hasPremiumFeatures" class="deactivated-label iconoir-lock"></i
></SwitchInput>
<i v-if="!hasPremiumFeatures" class="deactivated-label iconoir-lock" />
</SwitchInput>
<PremiumModal
v-if="!hasPremiumFeatures"

View file

@ -0,0 +1,59 @@
<!--
Additional 'sync with external calendar' button for share view link.
This button will be rendered differently depending on view.ical_public
-->
<template>
<ButtonText
tag="a"
type="secondary"
class="button-text--no-underline"
:icon="iconCssClasses"
@click="onClick"
>
{{
view.ical_public
? $t('calendarViewType.sharedViewDisableSyncToExternalCalendar')
: $t('calendarViewType.sharedViewEnableSyncToExternalCalendar')
}}
</ButtonText>
</template>
<script>
import { notifyIf } from '@baserow/modules/core/utils/error'
export default {
name: 'CalendarCreateIcalSharedViewLink',
props: {
view: { type: Object, required: false, default: null },
},
computed: {
iconCssClasses() {
const css = this.view.ical_public
? ['iconoir-cancel', 'view_sharing__create-link-icon']
: ['iconoir-calendar']
return css.join(' ')
},
},
methods: {
async onClick(evt) {
await this.updateView({ ical_public: !this.view.ical_public })
this.$emit('update-view', { ...this.view })
},
async updateView(values) {
const view = this.view
try {
await this.$store.dispatch('view/update', {
view,
values,
refreshFromFetch: true,
})
} catch (error) {
notifyIf(error, 'view')
}
},
},
}
</script>

View file

@ -0,0 +1,95 @@
<template>
<!-- extra section for shared view with ical/ics url -->
<div v-if="view.ical_public" class="view-sharing__shared-link">
<div class="view-sharing__shared-link-title">
{{ $t('calendarViewType.sharedViewTitle', { viewTypeSharingLinkName }) }}
</div>
<div class="view-sharing__shared-link-description">
{{
$t('calendarViewType.sharedViewDescription', {
viewTypeSharingLinkName,
})
}}
</div>
<div class="view-sharing__shared-link-content">
<span v-if="!view.ical_feed_url" class="view-sharing__loading-icon" />
<div :class="shareUrlCss">
{{ shareUrl }}
</div>
<a
v-tooltip="$t('shareViewLink.copyURL')"
class="view-sharing__shared-link-action"
@click="copyShareUrlToClipboard()"
>
<i class="iconoir-copy"></i>
<Copied ref="copied"></Copied>
</a>
<a
v-if="!readOnly"
v-tooltip="$t('shareViewLink.generateNewUrl')"
class="view-sharing__shared-link-action"
@click.prevent="$refs.rotateSlugModal.show()"
>
<i class="iconoir-refresh-double"></i>
<ViewRotateSlugModal
ref="rotateSlugModal"
:view="view"
:service="viewService"
/>
</a>
</div>
</div>
</template>
<script>
import Copied from '@baserow/modules/core/components/Copied'
import ViewRotateSlugModal from '@baserow/modules/database/components/view/ViewRotateSlugModal'
import { copyToClipboard } from '@baserow/modules/database/utils/clipboard'
import CalendarService from '@baserow_premium/services/views/calendar.js'
export default {
name: 'CalendarSharingIcalSlugSection',
components: { ViewRotateSlugModal, Copied },
props: {
view: {
type: Object,
required: true,
},
readOnly: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
viewService: CalendarService(this.$client),
}
},
computed: {
shareUrl() {
return this.view.ical_feed_url
},
shareUrlCss() {
return {
'view-sharing__shared-link-box': true,
'view-sharing__shared-link-box--short': !this.view.ical_feed_url,
}
},
viewType() {
return this.$registry.get('view', this.view.type)
},
viewTypeSharingLinkName() {
return this.viewType.getSharingLinkName()
},
},
methods: {
copyShareUrlToClipboard() {
copyToClipboard(this.shareUrl)
this.$refs.copied.show()
},
},
}
</script>

View file

@ -195,6 +195,13 @@
"tryAgain": "Try again",
"new": "New"
},
"calendarViewType": {
"sharedViewText": "Allow anyone to see the data in this view or sync events to your external calendar.",
"sharedViewEnableSyncToExternalCalendar": "Sync to an external calendar",
"sharedViewDisableSyncToExternalCalendar": "Disable external sync",
"sharedViewDescription": "Paste this link into your calendar application",
"sharedViewTitle": "Sync to an external calendar"
},
"calendarViewHeader": {
"displayBy": "Display by",
"displayedBy": "Displayed by “{fieldName}” field",

View file

@ -59,5 +59,9 @@ export default (client) => {
return client.get(`/database/views/calendar/${calendarId}/${url}`, config)
},
rotateSlug(viewId) {
return client.post(`/database/views/calendar/${viewId}/ical_slug_rotate/`)
},
}
}

View file

@ -10,6 +10,8 @@ import CalendarViewHeader from '@baserow_premium/components/views/calendar/Calen
import PremiumModal from '@baserow_premium/components/PremiumModal'
import PremiumFeatures from '@baserow_premium/features'
import { isAdhocFiltering } from '@baserow/modules/database/utils/view'
import CalendarCreateIcalSharedViewLink from '@baserow_premium/components/views/calendar/CalendarCreateIcalSharedViewLink'
import CalendarSharingIcalSlugSection from '@baserow_premium/components/views/calendar/CalendarSharingIcalSlugSection'
class PremiumViewType extends ViewType {
getDeactivatedText() {
@ -23,6 +25,10 @@ class PremiumViewType extends ViewType {
isDeactivated(workspaceId) {
return !this.app.$hasFeature(PremiumFeatures.PREMIUM, workspaceId)
}
getAdditionalShareLinkOptions() {
return []
}
}
export class KanbanViewType extends PremiumViewType {
@ -450,4 +456,32 @@ export class CalendarViewType extends PremiumViewType {
)
}
}
getAdditionalCreateShareLinkOptions() {
return [CalendarCreateIcalSharedViewLink]
}
getAdditionalDisableSharedLinkOptions() {
return [CalendarCreateIcalSharedViewLink]
}
getAdditionalSharingSections() {
return [CalendarSharingIcalSlugSection]
}
getSharedViewText() {
return this.app.i18n.t('calendarViewType.sharedViewText')
}
isShared(view) {
return !!view.public || !!view.ical_public
}
populate(view) {
Object.assign(view, {
createShareViewText: this.getSharedViewText(),
})
return view
}
}

View file

@ -92,4 +92,20 @@ export default class MockPremiumServer extends MockServer {
.onPatch(`/database/view/${viewId}/premium`, expectedContents)
.reply(200, expectedContents)
}
expectCalendarViewUpdate(viewId, expectedContents) {
this.mock
.onPatch(`/database/views/${viewId}/`, { ical_public: true })
.reply((config) => {
return [200, expectedContents]
})
}
expectCalendarRefreshShareURLUpdate(viewId, expectedContents) {
this.mock
.onPost(`/database/views/calendar/${viewId}/ical_slug_rotate/`)
.reply((config) => {
return [200, expectedContents]
})
}
}

View file

@ -0,0 +1,299 @@
import { PremiumTestApp } from '@baserow_premium_test/helpers/premiumTestApp'
import MockPremiumServer from '@baserow_premium_test/fixtures/mockPremiumServer'
import ShareViewLink from '@baserow/modules/database/components/view/ShareViewLink'
import Context from '@baserow/modules/core/components/Context'
import flushPromises from 'flush-promises'
import ViewRotateSlugModal from '@baserow/modules/database/components/view/ViewRotateSlugModal.vue'
async function openShareViewLinkContext(testApp, view) {
const shareViewLinkComponent = await testApp.mount(ShareViewLink, {
propsData: {
view,
readOnly: false,
},
})
// open share view
await shareViewLinkComponent.find('.header__filter-link').trigger('click')
// find context container
const context = shareViewLinkComponent.findComponent(Context)
// which should be visible
expect(context.isVisible()).toBe(true)
return shareViewLinkComponent
}
describe('Premium Share View Calendar ical feed Tests', () => {
let testApp = null
let mockServer = null
beforeAll(() => {
testApp = new PremiumTestApp()
mockServer = new MockPremiumServer(testApp.mock)
})
afterEach(() => testApp.afterEach())
test('User with global premium can share ical feed url', async () => {
const workspace = { id: 1, name: 'testWorkspace' }
await testApp.getStore().dispatch('workspace/forceCreate', workspace)
const tableId = 1
const databaseId = 3
const viewId = 5
testApp.getStore().dispatch('application/forceCreate', {
id: databaseId,
type: 'database',
tables: [{ id: tableId }],
workspace,
})
const icalFeedUrl = '/aaaaAAAA.ics'
const view = {
id: viewId,
type: 'calendar',
public: false,
table: { database_id: databaseId },
show_logo: true,
isShared: false,
ical_feed_url: icalFeedUrl,
ical_public: false,
}
testApp.getStore().dispatch('view/forceCreate', { data: view })
testApp.giveCurrentUserGlobalPremiumFeatures()
const updatedView = {}
Object.assign(updatedView, view, {
ical_public: true,
isShared: true,
})
mockServer.expectCalendarViewUpdate(viewId, updatedView)
const shareViewLinkContext = await openShareViewLinkContext(testApp, view)
expect(shareViewLinkContext.props('view').type).toEqual('calendar')
expect(shareViewLinkContext.props('view').id).toEqual(viewId)
// initial state: no shared links yet, two buttons to enable sharing
expect(
shareViewLinkContext.findAll('.view-sharing__create-link')
).toHaveLength(2)
// ..including: one with sync with external calendar
expect(
shareViewLinkContext
.findAll('.view-sharing__create-link')
.filter((el) =>
el
.text()
.includes('calendarViewType.sharedViewEnableSyncToExternalCalendar')
)
).toHaveLength(1)
// sanity check: no footer links yet
expect(
shareViewLinkContext.findAll('.view-sharing__shared-link-foot')
).toHaveLength(0)
// click on share with external calendar link
expect(
shareViewLinkContext
.findAll('.view-sharing__create-link')
.filter((el) =>
el
.text()
.includes('calendarViewType.sharedViewEnableSyncToExternalCalendar')
)
).toHaveLength(1)
expect(
shareViewLinkContext.findAll('.view-sharing__create-link')
).toHaveLength(2)
// last .view-sharing__create-link is a element which needs to be clicked
await shareViewLinkContext
.findAll('.view-sharing__create-link')
.at(1)
.trigger('click')
// need to wait for async store stuff
await flushPromises()
// await shareViewLinkContext.vm.$nextTick();
// state changed: one shared-link element with ical_feed_url
expect(shareViewLinkContext.props('view').ical_feed_url).toEqual(
icalFeedUrl
)
expect(shareViewLinkContext.props('view').isShared).toEqual(true)
expect(shareViewLinkContext.props('view').public).toEqual(false)
// no create link big buttons
expect(
shareViewLinkContext.findAll('.view-sharing__create-link')
).toHaveLength(0)
// but two buttons in the footer, one to disable the sync
expect(
shareViewLinkContext
.findAll('.view-sharing__shared-link-disable')
.filter((el) =>
el
.text()
.includes('calendarViewType.sharedViewEnableSyncToExternalCalendar')
)
).toHaveLength(0)
expect(
shareViewLinkContext
.findAll('.view-sharing__shared-link-disable')
.filter((el) =>
el
.text()
.includes(
'calendarViewType.sharedViewDisableSyncToExternalCalendar'
)
)
).toHaveLength(1)
// one shared link option for calendar ical feed
expect(
shareViewLinkContext
.findAll('.view-sharing__shared-link')
.filter((el) =>
el.text().includes('calendarViewType.sharedViewDescription')
)
).toHaveLength(1)
expect(
shareViewLinkContext
.findAll('.view-sharing__shared-link-disable')
.filter((el) =>
el
.text()
.includes(
'calendarViewType.sharedViewDisableSyncToExternalCalendar'
)
)
).toHaveLength(1)
})
test('User with global premium can rotate ical feed slug', async () => {
const workspace = { id: 1, name: 'testWorkspace' }
await testApp.getStore().dispatch('workspace/forceCreate', workspace)
const tableId = 1
const databaseId = 3
const viewId = 5
testApp.getStore().dispatch('application/forceCreate', {
id: databaseId,
type: 'database',
tables: [{ id: tableId }],
workspace,
})
const icalFeedUrl = '/aaaaAAAA.ics'
const icalFeedRotatedUrl = '/bbbbBBBB.ics'
const view = {
id: viewId,
type: 'calendar',
public: false,
table: { database_id: databaseId },
show_logo: true,
isShared: false,
ical_feed_url: icalFeedUrl,
ical_public: false,
}
testApp.getStore().dispatch('view/forceCreate', { data: view })
testApp.giveCurrentUserGlobalPremiumFeatures()
const publicView = {}
Object.assign(publicView, view, {
// ical_feed_url: icalFeedUrl,
isShared: true,
ical_public: true,
})
const rotatedSlugView = {}
Object.assign(rotatedSlugView, view, {
ical_feed_url: icalFeedRotatedUrl,
isShared: true,
ical_public: true,
})
mockServer.expectCalendarViewUpdate(viewId, publicView)
mockServer.expectCalendarRefreshShareURLUpdate(viewId, rotatedSlugView)
const shareViewLinkContext = await openShareViewLinkContext(testApp, view)
// sanity checks
expect(shareViewLinkContext.props('view').type).toEqual('calendar')
expect(shareViewLinkContext.props('view').id).toEqual(viewId)
// initial state: no shared links yet, two buttons to enable sharing
console.log('share view link', shareViewLinkContext)
console.log(
'buttons',
shareViewLinkContext.findAll('.view-sharing__create-link')
)
expect(
shareViewLinkContext.findAll('.view-sharing__create-link')
).toHaveLength(2)
// ..including: one with sync with external calendar
expect(
shareViewLinkContext
.findAll('.view-sharing__create-link')
.filter((el) =>
el
.text()
.includes('calendarViewType.sharedViewEnableSyncToExternalCalendar')
)
).toHaveLength(1)
// last .view-sharing__create-link is a element which needs to be clicked
await shareViewLinkContext
.findAll('.view-sharing__create-link')
.at(1)
.trigger('click')
// need to wait for async store stuff
await flushPromises()
// state changed: one shared-link element with ical_feed_url
expect(shareViewLinkContext.props('view').ical_feed_url).toEqual(
icalFeedUrl
)
expect(shareViewLinkContext.props('view').isShared).toEqual(true)
expect(shareViewLinkContext.props('view').public).toEqual(false)
// check for rotate slug component
expect(
shareViewLinkContext.findComponent(ViewRotateSlugModal)
).toBeInstanceOf(Object)
// it should be the last one out of two
expect(
shareViewLinkContext.findAll('.view-sharing__shared-link-action')
).toHaveLength(2)
// refresh slug click..
await shareViewLinkContext
.findAll('.view-sharing__shared-link-action')
.at(1)
.trigger('click')
// ..but this opens a modal with a button!
expect(
shareViewLinkContext
.findComponent(ViewRotateSlugModal)
.findAll('.actions button')
).toHaveLength(1)
// which has a button to click
shareViewLinkContext
.findComponent(ViewRotateSlugModal)
.findAll('.actions button')
.at(0)
.trigger('click')
await flushPromises()
await shareViewLinkContext.vm.$nextTick()
expect(shareViewLinkContext.props('view').ical_feed_url).toEqual(
icalFeedRotatedUrl
)
})
})

View file

@ -46,6 +46,7 @@ describe('Premium Share View Link Tests', () => {
public: true,
table: { database_id: databaseId },
show_logo: true,
isShared: true,
}
testApp.getStore().dispatch('view/forceCreate', { data: view })

View file

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.2777 10.7266C11.6912 9.14012 8.62049 9.01983 6.82125 10.8191C6.60813 11.0322 5.90562 11.7347 5.72977 11.9106C3.92135 13.719 3.87994 16.6096 5.6373 18.367C7.15163 19.8813 9.50747 20.06 11.2872 18.9233C11.5728 18.7409 11.8436 18.5246 12.0937 18.2745" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.7309 13.2734C12.3173 14.8599 15.3881 14.9802 17.1873 13.1809C17.4004 12.9678 18.1029 12.2653 18.2788 12.0894C20.0872 10.281 20.1286 7.39038 18.3712 5.63303C16.8569 4.1187 14.5011 3.94002 12.7213 5.07669C12.4357 5.25911 12.1649 5.47541 11.9148 5.7255" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

(image error) Size: 795 B

View file

@ -1,13 +1,62 @@
.view-sharing__create-link {
/**
layout changes for shared links (smaller padding)
for not-yet shared use `share` naming
for shared use `shared` naming
*/
.view-sharing {
max-height: inherit;
display: flex;
flex-direction: column;
&--scrollable {
overflow-y: scroll;
}
}
.view-sharing__create-link-container {
display: flex;
align-items: center;
}
.view-sharing__loading-icon {
display: block;
line-height: 56px;
font-size: 14px;
font-weight: 600;
padding: 0 20px;
color: $color-neutral-900;
width: 16px;
height: 16px;
border: 2px;
margin-left: 4px;
margin-right: 16px;
top: -2px;
position: relative;
@include rounded($rounded-md);
@include flex-align-items(20px);
&::before {
content: '';
z-index: 1;
@include loading(14px);
}
}
.view-sharing__create-link {
display: flex;
column-gap: 8px;
justify-items: center;
line-height: 36px;
font-size: 13px;
font-weight: 500;
padding: 0 12px;
margin-top: 36px;
margin-right: 12px;
color: $palette-neutral-1200;
border: solid 1px $palette-neutral-400;
@include rounded($rounded-md);
&:last-child {
margin-right: 0;
}
&:hover {
text-decoration: none;
@ -24,53 +73,110 @@
}
}
/** icons */
.view-sharing__create-link-icon {
color: $color-warning-500;
font-size: 20px;
color: $palette-neutral-900;
font-size: 16px;
.view-sharing__create-link--disabled & {
color: $color-neutral-500;
}
}
.view-sharing__share-link-container {
display: flex;
align-items: center;
justify-content: center;
}
.view-sharing__share-link {
padding: 48px 80px;
text-align: center;
box-sizing: content-box;
min-width: 460px;
}
.view-sharing__shared-link {
padding: 25px;
padding: 20px;
text-align: left;
box-sizing: content-box;
max-width: 624px;
color: $palette-neutral-900;
border-top: solid 1px $palette-neutral-200;
&:first-child {
border-top: none;
}
}
.view-sharing__share-link-title {
font-size: 16px;
font-weight: 500;
color: $palette-neutral-1200;
}
.view-sharing__share-link-description {
font-size: 12px;
font-weight: normal;
margin-top: 16px;
color: $palette-neutral-900;
}
.view-sharing__shared-link-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 10px;
font-size: 13px;
font-weight: 500;
line-height: 20px;
margin-bottom: 8px;
color: $palette-neutral-1200;
}
.view-sharing__shared-link-description {
font-size: 12px;
font-weight: 400;
line-height: 20px;
margin-bottom: 8px;
}
.view-sharing__share-link-content {
display: flex;
margin-bottom: 15px;
}
.view-sharing__shared-link-content {
display: flex;
margin-bottom: 15px;
align-items: center;
}
.view-sharing__shared-link-box {
background-color: $color-neutral-100;
background-color: $palette-neutral-100;
line-height: 32px;
padding: 0 8px;
font-family: monospace;
font-size: 10px;
min-width: 0;
width: 516px;
border: solid 1px $palette-neutral-400;
overscroll-behavior: none;
margin: 8px 0;
height: 32px;
@include scrollable-overflow;
@include rounded($rounded);
&.view-sharing__shared-link-box--short {
width: 484px;
}
}
.view-sharing__shared-link-action {
position: relative;
width: 32px;
margin-left: 8px;
height: 32px;
vertical-align: center;
margin-left: 4px;
line-height: 32px;
color: $color-neutral-900;
font-size: 14px;
text-align: center;
color: $palette-neutral-900;
@include rounded($rounded);
@ -94,7 +200,6 @@
}
&.view-sharing__shared-link-action--disabled {
color: $color-neutral-900;
background-color: transparent;
&:hover {
@ -105,21 +210,26 @@
.view-sharing__shared-link-foot {
display: flex;
justify-content: space-between;
justify-content: left;
gap: 20px;
padding: 16px;
line-height: 20px;
border-top: solid 1px $palette-neutral-200;
}
.view-sharing__shared-link-disable {
@include flex-align-items(4px);
@include flex-align-items(8px);
color: $color-error-500;
color: $palette-neutral-900;
}
.view-sharing__shared-link-options {
margin-top: 10px;
margin-bottom: 20px;
margin-top: 14px;
}
.view-sharing__option {
margin-top: 14px;
@include flex-align-items(4px);
&--disabled {
@ -143,6 +253,5 @@ p.box__description {
&:hover {
text-decoration: none;
color: $color-neutral-900;
}
}

View file

@ -5,3 +5,4 @@
@import 'select_option';
@import 'collaborator';
@import 'roundness';
@import 'scrollable_container';

View file

@ -0,0 +1,8 @@
@mixin scrollable-overflow {
scrollbar-width: none;
overflow-y: scroll;
&::-webkit-scrollbar {
display: none;
}
}

View file

@ -76,4 +76,4 @@ $baserow-icons: 'circle-empty', 'circle-checked', 'check-square', 'formula',
'file-audio', 'file-video', 'file-code', 'tablet', 'form', 'file-excel',
'kanban', 'file-word', 'file-archive', 'gallery', 'file-powerpoint',
'calendar', 'smile', 'smartphone', 'plus', 'heading-1', 'heading-2',
'heading-3', 'paragraph', 'ordered-list', 'enlarge', 'settings';
'heading-3', 'paragraph', 'ordered-list', 'enlarge', 'share', 'settings';

View file

@ -103,7 +103,7 @@ export class BaserowPlugin extends Registerable {
return null
}
/*
/**
* Every registered plugin can display multiple additional public share link options
* which will be visible on the share public view context.
*/

View file

@ -1,9 +1,10 @@
<template>
<!-- toolbar icon -->
<div>
<a
ref="contextLink"
class="header__filter-link"
:class="{ 'active--primary': view.public }"
:class="{ 'active--primary': view.isShared }"
@click="$refs.context.toggle($refs.contextLink, 'bottom', 'left', 4)"
>
<i class="header__filter-icon iconoir-share-android"></i>
@ -11,99 +12,173 @@
{{ $t('shareViewLink.shareView', { viewTypeSharingLinkName }) }}
</span>
</a>
<Context
ref="context"
:overflow-scroll="true"
:max-height-if-outside-viewport="true"
>
<a
v-if="!view.public"
class="view-sharing__create-link"
:class="{ 'view-sharing__create-link--disabled': readOnly }"
@click.stop="!readOnly && updateView({ public: true })"
>
<i class="iconoir-share-android view-sharing__create-link-icon"></i>
{{ $t('shareViewLink.shareViewTitle', { viewTypeSharingLinkName }) }}
</a>
<div v-else class="view-sharing__shared-link">
<div class="view-sharing__shared-link-title">
{{ $t('shareViewLink.sharedViewTitle', { viewTypeSharingLinkName }) }}
</div>
<div class="view-sharing__shared-link-description">
{{
$t('shareViewLink.sharedViewDescription', {
viewTypeSharingLinkName,
})
}}
</div>
<div class="view-sharing__shared-link-content">
<div class="view-sharing__shared-link-box">{{ shareUrl }}</div>
<a
v-tooltip="$t('shareViewLink.copyURL')"
class="view-sharing__shared-link-action"
@click="copyShareUrlToClipboard()"
>
<i class="iconoir-copy"></i>
<Copied ref="copied"></Copied>
</a>
<a
v-if="!readOnly"
v-tooltip="$t('shareViewLink.generateNewUrl')"
class="view-sharing__shared-link-action"
@click.prevent="$refs.rotateSlugModal.show()"
>
<i class="iconoir-refresh-double"></i>
<ViewRotateSlugModal
ref="rotateSlugModal"
:view="view"
></ViewRotateSlugModal>
</a>
</div>
<div class="view-sharing__shared-link-options">
<div class="view-sharing__option margin-bottom-1">
<SwitchInput
small
:value="view.public_view_has_password"
@input="toggleShareViewPassword"
>
<i
class="view-sharing__option-icon"
:class="[
view.public_view_has_password
? 'iconoir-lock'
: 'iconoir-globe',
]"
></i>
<span>{{ $t(optionPasswordText) }}</span>
</SwitchInput>
<a
v-if="view.public_view_has_password"
class="view-sharing__option-change-password"
@click.stop="$refs.enablePasswordModal.show"
>
{{ $t('shareViewLink.ChangePassword') }}
<i class="iconoir-edit-pencil"></i>
</a>
<EnablePasswordModal ref="enablePasswordModal" :view="view" />
<DisablePasswordModal ref="disablePasswordModal" :view="view" />
</div>
<!-- modal -->
<Context ref="context" :max-height-if-outside-viewport="true">
<!-- is not yet shared -->
<div v-if="!view.isShared" class="view-sharing__share-link">
<div class="view-sharing__share-link-title">
{{ $t('shareViewLink.shareViewTitle', { viewTypeSharingLinkName }) }}
</div>
<!-- custom shared view text provided by viewType, should be i18n'ed already -->
<div class="view-sharing__share-link-description">
{{ view.createShareViewText || $t('shareViewLink.shareViewText') }}
</div>
<div
v-auto-overflow-scroll="true"
class="view-sharing__share-link-container"
>
<ButtonText
tag="a"
class="view-sharing__create-link"
:class="{ 'view-sharing__create-link--disabled': readOnly }"
icon="baserow-icon-share view-sharing__create-link-icon"
@click.stop="!readOnly && updateView({ public: true })"
>
{{
$t('shareViewLink.shareViewLinkTitle', {
viewTypeSharingLinkName,
})
}}
</ButtonText>
<component
:is="component"
v-for="(component, i) in additionalShareLinkOptions"
:is="extraLink"
v-for="(extraLink, i) in additionalCreateShareLinkOptions"
:key="i"
class="view-sharing__create-link"
:view="view"
@update-view="forceUpdateView"
/>
</div>
</div>
<div v-else class="view-sharing">
<div v-auto-overflow-scroll="true" class="view-sharing--scrollable">
<div v-if="view.public" class="view-sharing__shared-link">
<!-- title and description -->
<div class="view-sharing__shared-link-title">
{{
$t('shareViewLink.sharedViewTitle', { viewTypeSharingLinkName })
}}
</div>
<div class="view-sharing__shared-link-description">
{{
$t('shareViewLink.sharedViewDescription', {
viewTypeSharingLinkName,
})
}}
</div>
<!-- generated url bar -->
<div class="view-sharing__shared-link-content">
<div class="view-sharing__shared-link-box">{{ shareUrl }}</div>
<a
v-tooltip="$t('shareViewLink.copyURL')"
class="view-sharing__shared-link-action"
@click="copyShareUrlToClipboard()"
>
<i class="iconoir-copy" />
<Copied ref="copied"></Copied>
</a>
<a
v-if="!readOnly"
v-tooltip="$t('shareViewLink.generateNewUrl')"
class="view-sharing__shared-link-action"
@click.prevent="$refs.rotateSlugModal.show()"
>
<i class="iconoir-refresh-double" />
<ViewRotateSlugModal
ref="rotateSlugModal"
:service="viewService"
:view="view"
></ViewRotateSlugModal>
</a>
</div>
<div class="view-sharing__shared-link-options">
<div class="view-sharing__option">
<SwitchInput
small
:value="view.public_view_has_password"
@input="toggleShareViewPassword"
>
<i
class="view-sharing__option-icon"
:class="[
view.public_view_has_password
? 'iconoir-lock'
: 'iconoir-globe',
]"
/>
<span>{{ $t(optionPasswordText) }}</span>
</SwitchInput>
<a
v-if="view.public_view_has_password"
class="view-sharing__option-change-password"
@click.stop="$refs.enablePasswordModal.show"
>
{{ $t('shareViewLink.ChangePassword') }}
<i class="iconoir-edit-pencil" />
</a>
<EnablePasswordModal ref="enablePasswordModal" :view="view" />
<DisablePasswordModal ref="disablePasswordModal" :view="view" />
</div>
<component
:is="component"
v-for="(component, i) in additionalShareLinkOptions"
:key="i"
:view="view"
@update-view="forceUpdateView"
/>
</div>
</div>
<component
:is="sharingSection"
v-for="(sharingSection, i) in additionalSharingSections"
:key="i"
:view="view"
@update-view="forceUpdateView"
/>
</div>
<div v-if="!readOnly" class="view-sharing__shared-link-foot">
<a
class="view-sharing__shared-link-disable"
<ButtonText
v-if="view.public"
tag="a"
class="view-sharing__shared-link-disable button-text--no-underline"
type="secondary"
icon="iconoir-cancel"
@click.stop="updateView({ public: false })"
>
<i class="iconoir-cancel"></i>
{{ $t('shareViewLink.disableLink') }}
</a>
</ButtonText>
<ButtonText
v-else
tag="a"
class="view-sharing__shared-link-disable button-text--no-underline"
type="secondary"
icon="baserow-icon-share"
@click.stop="!readOnly && updateView({ public: true })"
>
{{
$t('shareViewLink.shareViewLinkTitle', {
viewTypeSharingLinkName,
})
}}
</ButtonText>
<component
:is="comp"
v-for="(comp, i) in additionalDisableSharedLinkOptions"
:key="i"
:view="view"
class="view-sharing__shared-link-disable"
@update-view="forceUpdateView"
/>
</div>
</div>
</Context>
@ -116,6 +191,7 @@ import ViewRotateSlugModal from '@baserow/modules/database/components/view/ViewR
import EnablePasswordModal from '@baserow/modules/database/components/view/public/EnablePasswordModal'
import DisablePasswordModal from '@baserow/modules/database/components/view/public/DisablePasswordModal'
import { notifyIf } from '@baserow/modules/core/utils/error'
import ViewService from '@baserow/modules/database/services/view'
export default {
name: 'ShareViewLink',
@ -137,6 +213,7 @@ export default {
data() {
return {
rotateSlugLoading: false,
viewService: ViewService(this.$client),
}
},
computed: {
@ -160,13 +237,23 @@ export default {
viewTypeSharingLinkName() {
return this.viewType.getSharingLinkName()
},
additionalCreateShareLinkOptions() {
return this.viewType.getAdditionalCreateShareLinkOptions()
},
additionalDisableSharedLinkOptions() {
return this.viewType.getAdditionalDisableSharedLinkOptions()
},
additionalShareLinkOptions() {
return Object.values(this.$registry.getAll('plugin'))
const opts = Object.values(this.$registry.getAll('plugin'))
.reduce((components, plugin) => {
components = components.concat(plugin.getAdditionalShareLinkOptions())
return components
}, [])
.filter((component) => component !== null)
return opts
},
additionalSharingSections() {
return this.viewType.getAdditionalSharingSections()
},
},
methods: {
@ -193,6 +280,7 @@ export default {
this.$store.dispatch('view/forceUpdate', {
view: this.view,
values,
repopulate: true,
})
},
toggleShareViewPassword() {

View file

@ -31,7 +31,6 @@
<script>
import modal from '@baserow/modules/core/mixins/modal'
import error from '@baserow/modules/core/mixins/error'
import ViewService from '@baserow/modules/database/services/view'
export default {
name: 'ViewRotateSlugModal',
@ -41,6 +40,14 @@ export default {
type: Object,
required: true,
},
/**
* Service to call to rotate the slug.
* It should have .rotateSlug(viewId) method.
*/
service: {
type: Object,
required: true,
},
},
data() {
return {
@ -58,9 +65,7 @@ export default {
this.loading = true
try {
const { data } = await ViewService(this.$client).rotateSlug(
this.view.id
)
const { data } = await this.service.rotateSlug(this.view.id)
await this.$store.dispatch('view/forceUpdate', {
view: this.view,
values: data,

View file

@ -479,16 +479,19 @@
"add": "Add {view}"
},
"shareViewLink": {
"shareViewText": "Private shareable link allows anyone to see the data in this view.",
"shareView": "Share {viewTypeSharingLinkName}",
"shareViewTitle": "Create a private shareable link to the {viewTypeSharingLinkName}",
"shareViewLinkTitle": "Create a private link",
"shareViewTitle": "You have not yet shared the view",
"sharedViewTitle": "This {viewTypeSharingLinkName} is currently shared via a private link",
"sharedViewDescription": "People who have the link can see the {viewTypeSharingLinkName}.",
"disableLink": "disable shared link",
"disableLink": "Disable shared link",
"generateNewUrl": "generate new url",
"copyURL": "copy URL",
"EnablePassword": "Restrict access with a password",
"DisablePassword": "Access is password-protected",
"ChangePassword": "Change"
"ChangePassword": "Change",
"notSharedYetText": "Allow anyone to see the data in this view or sync events to your external calendar."
},
"viewGroupByContext": {
"noGroupByTitle": "You have not yet created any groupings",

View file

@ -276,7 +276,11 @@ export const registerRealtimeEvents = (realtime) => {
const view = store.getters['view/get'](data.view.id)
if (view !== undefined) {
const oldView = clone(view)
store.dispatch('view/forceUpdate', { view, values: data.view })
store.dispatch('view/forceUpdate', {
view,
values: data.view,
repopulate: true,
})
if (view.id === store.getters['view/getSelectedId']) {
const viewType = app.$registry.get('view', view.type)

View file

@ -56,13 +56,15 @@ export function populateDecoration(decoration) {
export function populateView(view, registry) {
const type = registry.get('view', view.type)
view._ = {
view._ = view._ || {
type: type.serialize(),
selected: false,
loading: false,
focusFilter: null,
}
view.isShared = type.isShared(view)
if (Object.prototype.hasOwnProperty.call(view, 'filters')) {
view.filters.forEach((filter) => {
populateFilter(filter)
@ -400,7 +402,10 @@ export const actions = {
/**
* Updates the values of the view with the provided id.
*/
async update({ commit, dispatch }, { view, values, readOnly = false }) {
async update(
{ commit, dispatch },
{ view, values, readOnly = false, refreshFromFetch = false }
) {
commit('SET_ITEM_LOADING', { view, value: true })
const oldValues = {}
const newValues = {}
@ -428,7 +433,7 @@ export const actions = {
})
}
dispatch('forceUpdate', { view, values: newValues })
dispatch('forceUpdate', { view, values: newValues, repopulate: true })
try {
if (!readOnly) {
dispatch(
@ -438,7 +443,14 @@ export const actions = {
root: true,
}
)
await ViewService(this.$client).update(view.id, values)
// in some cases view may return extra data that were not present in values
const newValues = (
await ViewService(this.$client).update(view.id, values)
).data
if (refreshFromFetch) {
dispatch('forceUpdate', { view, values: newValues, repopulate: true })
}
updatePublicViewHasPassword()
}
commit('SET_ITEM_LOADING', { view, value: false })

View file

@ -8,12 +8,13 @@ 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 {
newFieldMatchesActiveSearchTerm,
isAdhocFiltering,
isAdhocSorting,
newFieldMatchesActiveSearchTerm,
} from '@baserow/modules/database/utils/view'
import { clone } from '@baserow/modules/core/utils/object'
import { getDefaultSearchModeFromEnv } from '@baserow/modules/database/utils/search'
export const maxPossibleOrderValue = 32767
export class ViewType extends Registerable {
@ -174,6 +175,14 @@ export class ViewType extends Registerable {
)
}
/**
* Returns true if view is already configured to be shared. This may be just
* a public flag state or more complex per-view calculation
*/
isShared(view) {
return !!view.public
}
/**
* The fetch method is called inside the asyncData function when the table page
* loads with a selected view. It is possible to fill some stores serverside here.
@ -371,6 +380,92 @@ export class ViewType extends Registerable {
hidden: false,
}
}
/**
ShareView component allows for customization which requires a bit of explaination.
ViewType may provide additional components through specific methods which are
injected into specific places in ShareViewLink component.
Below is a draft of placement of those additional components
# shareview create state #
+---------------------------------------------------------------------+
| |
| ${ getSharedViewText() } |
| [share view button] [..getAdditionalCreateShareLinkOptions()] |
| |
+---------------------------------------------------------------------+
# shareview shared state #
+-----------------------------------------------------------------------+
| |
| this view is currently shared via a private link |
| [ share URL ] [ copy ] [ rotate url ] |
| [ restrict access with password] |
| [ ..getAdditionalShareLinkOPptions()] |
| |
[-----------------------------------------------------------------------]
[ ]
[ ..getAdditionalSharingSections() shoudl provide ]
[ whole component. It should be in sync with footer link options ]
[ ]
+-----------------------------------------------------------------------+
| [disalbe/enable sharing] [..getAdditionalDisableSharedLinkOptions()] |
+-----------------------------------------------------------------------+
*/
/**
* Every registered view can display multiple additional public share link options
* which will be visible on the share public view context. Those options are added
* to a default share link component only.
*
* Additional sharing sections (which may be visible even if default share link is not)
* can be returned from getAdditionalSharingSections
*/
getAdditionalShareLinkOptions() {
return []
}
/**
* A view type can show additional options (links) in a create shared view
* modal. This should return a list of components to display.
*
* @returns {*[]}
*/
getAdditionalCreateShareLinkOptions() {
return []
}
/**
* Once a view is shared, additional disable shared link buttons may
* be presented in the footer
*/
getAdditionalDisableSharedLinkOptions() {
return []
}
/**
* Additional share link sections that are added after a default one. This method should
* return a list of Vue components. Each component will receive `view` and
* should be responsible for checking its visibility.
*
* In most cases state of components returned from here should be in sync with state
* of components returned by getAdditionalDisableSharedLinkOptions() visible in footer.
*/
getAdditionalSharingSections() {
return []
}
/**
* Custom translation text to return for Shared View text.
*
* @returns {null|String}
*/
getSharedViewText() {
return this.app.i18n.t('shareViewLink.shareViewText')
}
}
export class GridViewType extends ViewType {