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 baserow/baserow!2535
This commit is contained in:
commit
b99f8bbb6f
50 changed files with 2701 additions and 187 deletions
.env.example
backend
requirements
src/baserow
config/settings
contrib/database
changelog/entries/unreleased/feature
deploy/all-in-one/supervisor
docker-compose.local-build.ymldocker-compose.no-caddy.ymldocker-compose.ymldocs/installation
premium
backend
pyproject.toml
src/baserow_premium
tests/baserow_premium_tests
web-frontend
modules/baserow_premium
test
web-frontend/modules
|
@ -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=
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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={
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
}
|
|
@ -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:-}"
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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 shouldn’t need to set this yourself unless you are customizing the settings manually. | |
|
||||
| | | |
|
||||
| BASEROW\_BACKEND\_BIND\_ADDRESS | **INTERNAL** The address that Baserow’s 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 Baserow’s 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 shouldn’t need to set this yourself unless you are customizing the settings manually. | |
|
||||
| | | |
|
||||
| BASEROW\_BACKEND\_BIND\_ADDRESS | **INTERNAL** The address that Baserow’s 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 Baserow’s 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 |
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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",
|
||||
),
|
||||
]
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
238
premium/backend/src/baserow_premium/ical_utils.py
Normal file
238
premium/backend/src/baserow_premium/ical_utils.py
Normal 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
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
99
premium/backend/src/baserow_premium/views/actions.py
Normal file
99
premium/backend/src/baserow_premium/views/actions.py
Normal 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
|
||||
)
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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"
|
||||
|
|
278
premium/backend/tests/baserow_premium_tests/test_ical_utils.py
Normal file
278
premium/backend/tests/baserow_premium_tests/test_ical_utils.py
Normal 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"
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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",
|
||||
|
|
|
@ -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/`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
299
premium/web-frontend/test/unit/premium/calendarShareLink.spec.js
Normal file
299
premium/web-frontend/test/unit/premium/calendarShareLink.spec.js
Normal 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
|
||||
)
|
||||
})
|
||||
})
|
|
@ -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 })
|
||||
|
||||
|
|
4
web-frontend/modules/core/assets/icons/share.svg
Normal file
4
web-frontend/modules/core/assets/icons/share.svg
Normal 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 |
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,3 +5,4 @@
|
|||
@import 'select_option';
|
||||
@import 'collaborator';
|
||||
@import 'roundness';
|
||||
@import 'scrollable_container';
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
@mixin scrollable-overflow {
|
||||
scrollbar-width: none;
|
||||
overflow-y: scroll;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 })
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Add table
Reference in a new issue