diff --git a/changelog/entries/unreleased/feature/send_notification_when_a_periodic_data_sync_is_deactivated.json b/changelog/entries/unreleased/feature/send_notification_when_a_periodic_data_sync_is_deactivated.json new file mode 100644 index 000000000..9f150f20c --- /dev/null +++ b/changelog/entries/unreleased/feature/send_notification_when_a_periodic_data_sync_is_deactivated.json @@ -0,0 +1,7 @@ +{ + "type": "feature", + "message": "Send notification to authorized user when periodic data sync is deactivated.", + "issue_number": null, + "bullet_points": [], + "created_at": "2025-02-13" +} diff --git a/enterprise/backend/src/baserow_enterprise/apps.py b/enterprise/backend/src/baserow_enterprise/apps.py index 31ac1b1b5..7f916b88f 100755 --- a/enterprise/backend/src/baserow_enterprise/apps.py +++ b/enterprise/backend/src/baserow_enterprise/apps.py @@ -287,6 +287,15 @@ class BaserowEnterpriseConfig(AppConfig): connect_to_post_delete_signals_to_cascade_deletion_to_role_assignments() + from baserow.core.notifications.registries import notification_type_registry + from baserow_enterprise.data_sync.notification_types import ( + PeriodicDataSyncDeactivatedNotificationType, + ) + + notification_type_registry.register( + PeriodicDataSyncDeactivatedNotificationType() + ) + # The signals must always be imported last because they use the registries # which need to be filled first. import baserow_enterprise.audit_log.signals # noqa: F diff --git a/enterprise/backend/src/baserow_enterprise/data_sync/handler.py b/enterprise/backend/src/baserow_enterprise/data_sync/handler.py index 841066bd4..d9f249e6e 100644 --- a/enterprise/backend/src/baserow_enterprise/data_sync/handler.py +++ b/enterprise/backend/src/baserow_enterprise/data_sync/handler.py @@ -24,6 +24,7 @@ from baserow_enterprise.data_sync.models import ( ) from baserow_enterprise.features import DATA_SYNC +from .notification_types import PeriodicDataSyncDeactivatedNotificationType from .tasks import sync_periodic_data_sync @@ -216,6 +217,15 @@ class EnterpriseDataSyncHandler: >= settings.BASEROW_ENTERPRISE_MAX_PERIODIC_DATA_SYNC_CONSECUTIVE_ERRORS ): periodic_data_sync.automatically_deactivated = True + + # Send a notification to the authorized user that the periodic data + # sync was deactivated. + transaction.on_commit( + lambda: PeriodicDataSyncDeactivatedNotificationType.notify_authorized_user( + periodic_data_sync + ) + ) + periodic_data_sync.save() elif periodic_data_sync.consecutive_failed_count > 0: # Once it runs successfully, the consecutive count can be reset because we diff --git a/enterprise/backend/src/baserow_enterprise/data_sync/notification_types.py b/enterprise/backend/src/baserow_enterprise/data_sync/notification_types.py new file mode 100644 index 000000000..2c1bd21a2 --- /dev/null +++ b/enterprise/backend/src/baserow_enterprise/data_sync/notification_types.py @@ -0,0 +1,77 @@ +from dataclasses import asdict, dataclass +from typing import List, Optional + +from django.conf import settings +from django.utils.translation import gettext as _ + +from baserow.core.notifications.handler import NotificationHandler +from baserow.core.notifications.models import NotificationRecipient +from baserow.core.notifications.registries import ( + EmailNotificationTypeMixin, + NotificationType, +) + +from .models import PeriodicDataSyncInterval + + +@dataclass +class DeactivatedPeriodicDataSyncData: + data_sync_id: int + table_name: str + table_id: int + database_id: int + + @classmethod + def from_periodic_data_sync(cls, periodic_data_sync: PeriodicDataSyncInterval): + return cls( + data_sync_id=periodic_data_sync.data_sync_id, + table_name=periodic_data_sync.data_sync.table.name, + table_id=periodic_data_sync.data_sync.table.id, + database_id=periodic_data_sync.data_sync.table.database_id, + ) + + +class PeriodicDataSyncDeactivatedNotificationType( + EmailNotificationTypeMixin, NotificationType +): + type = "periodic_data_sync_deactivated" + has_web_frontend_route = True + + @classmethod + def notify_authorized_user( + cls, periodic_data_sync: PeriodicDataSyncInterval + ) -> Optional[List[NotificationRecipient]]: + """ + Creates a notification for the authorized user of the periodic data sync + that was deactivated. + + :param periodic_data_sync: The periodic data sync that was deactivated. + """ + + workspace = periodic_data_sync.data_sync.table.database.workspace + return NotificationHandler.create_direct_notification_for_users( + notification_type=PeriodicDataSyncDeactivatedNotificationType.type, + recipients=[periodic_data_sync.authorized_user], + data=asdict( + DeactivatedPeriodicDataSyncData.from_periodic_data_sync( + periodic_data_sync + ) + ), + sender=None, + workspace=workspace, + ) + + @classmethod + def get_notification_title_for_email(cls, notification, context): + return _("%(name)s periodic data sync has been deactivated.") % { + "name": notification.data["table_name"], + } + + @classmethod + def get_notification_description_for_email(cls, notification, context): + return _( + "The periodic data sync failed more than %(max_failures)s consecutive times" + "and was therefore deactivated." + ) % { + "max_failures": settings.BASEROW_ENTERPRISE_MAX_PERIODIC_DATA_SYNC_CONSECUTIVE_ERRORS, + } diff --git a/enterprise/backend/src/baserow_enterprise/locale/en/LC_MESSAGES/django.po b/enterprise/backend/src/baserow_enterprise/locale/en/LC_MESSAGES/django.po index f6f380813..33f30af25 100644 --- a/enterprise/backend/src/baserow_enterprise/locale/en/LC_MESSAGES/django.po +++ b/enterprise/backend/src/baserow_enterprise/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-11 17:20+0000\n" +"POT-Creation-Date: 2025-02-13 13:04+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" @@ -71,6 +71,18 @@ msgstr "" msgid "Data sync table \"%(table_name)s\" (%(table_id)s) updated" msgstr "" +#: src/baserow_enterprise/data_sync/notification_types.py:63 +#, python-format +msgid "%(name)s periodic data sync has been deactivated." +msgstr "" + +#: src/baserow_enterprise/data_sync/notification_types.py:70 +#, python-format +msgid "" +"The periodic data sync failed more than %(max_failures)s consecutive " +"timesand was therefore deactivated." +msgstr "" + #: src/baserow_enterprise/role/actions.py:28 msgid "Assign multiple roles" msgstr "" diff --git a/enterprise/backend/tests/baserow_enterprise_tests/data_sync/test_enterprise_data_sync_handler.py b/enterprise/backend/tests/baserow_enterprise_tests/data_sync/test_enterprise_data_sync_handler.py index 66d370d1d..da6d42591 100644 --- a/enterprise/backend/tests/baserow_enterprise_tests/data_sync/test_enterprise_data_sync_handler.py +++ b/enterprise/backend/tests/baserow_enterprise_tests/data_sync/test_enterprise_data_sync_handler.py @@ -14,8 +14,12 @@ from freezegun.api import freeze_time from baserow.contrib.database.data_sync.handler import DataSyncHandler from baserow.contrib.database.data_sync.models import DataSync from baserow.core.exceptions import UserNotInWorkspace +from baserow.core.notifications.models import Notification from baserow_enterprise.data_sync.handler import EnterpriseDataSyncHandler from baserow_enterprise.data_sync.models import PeriodicDataSyncInterval +from baserow_enterprise.data_sync.notification_types import ( + PeriodicDataSyncDeactivatedNotificationType, +) @pytest.mark.django_db @@ -611,6 +615,53 @@ def test_sync_periodic_data_sync_deactivated_max_failure(enterprise_data_fixture assert periodic_data_sync.automatically_deactivated is True +@pytest.mark.django_db(transaction=True) +@override_settings(DEBUG=True) +@responses.activate +def test_sync_periodic_data_sync_deactivated_max_failure_notification_send( + enterprise_data_fixture, +): + responses.add( + responses.GET, + "https://baserow.io/ical.ics", + status=404, + body="", + ) + + enterprise_data_fixture.enable_enterprise() + user = enterprise_data_fixture.create_user() + + periodic_data_sync = EnterpriseDataSyncHandler.update_periodic_data_sync_interval( + user=user, + data_sync=enterprise_data_fixture.create_ical_data_sync(user=user), + interval="DAILY", + when=time(hour=12, minute=10, second=1, microsecond=1), + ) + periodic_data_sync.consecutive_failed_count = 3 + periodic_data_sync.save() + + with transaction.atomic(): + EnterpriseDataSyncHandler.sync_periodic_data_sync(periodic_data_sync.id) + + all_notifications = list(Notification.objects.all()) + assert len(all_notifications) == 1 + recipient_ids = [r.id for r in all_notifications[0].recipients.all()] + assert recipient_ids == [user.id] + assert all_notifications[0].type == PeriodicDataSyncDeactivatedNotificationType.type + assert all_notifications[0].broadcast is False + assert ( + all_notifications[0].workspace_id + == periodic_data_sync.data_sync.table.database.workspace_id + ) + assert all_notifications[0].sender is None + assert all_notifications[0].data == { + "data_sync_id": periodic_data_sync.data_sync_id, + "table_name": periodic_data_sync.data_sync.table.name, + "table_id": periodic_data_sync.data_sync.table.id, + "database_id": periodic_data_sync.data_sync.table.database_id, + } + + @pytest.mark.django_db @override_settings(DEBUG=True) def test_sync_periodic_data_sync_authorized_user_is_none(enterprise_data_fixture): diff --git a/enterprise/backend/tests/baserow_enterprise_tests/data_sync/test_periodic_data_sync_notification_types.py b/enterprise/backend/tests/baserow_enterprise_tests/data_sync/test_periodic_data_sync_notification_types.py new file mode 100644 index 000000000..9e67e406a --- /dev/null +++ b/enterprise/backend/tests/baserow_enterprise_tests/data_sync/test_periodic_data_sync_notification_types.py @@ -0,0 +1,47 @@ +from datetime import time + +from django.test.utils import override_settings + +import pytest + +from baserow_enterprise.data_sync.handler import EnterpriseDataSyncHandler +from baserow_enterprise.data_sync.notification_types import ( + PeriodicDataSyncDeactivatedNotificationType, +) + + +@override_settings(DEBUG=True) +@pytest.mark.django_db(transaction=True) +def test_webhook_deactivated_notification_can_be_render_as_email( + api_client, enterprise_data_fixture +): + enterprise_data_fixture.enable_enterprise() + + user = enterprise_data_fixture.create_user() + periodic_data_sync = EnterpriseDataSyncHandler.update_periodic_data_sync_interval( + user=user, + data_sync=enterprise_data_fixture.create_ical_data_sync(user=user), + interval="DAILY", + when=time(hour=12, minute=10, second=1, microsecond=1), + ) + + notification_recipients = ( + PeriodicDataSyncDeactivatedNotificationType.notify_authorized_user( + periodic_data_sync + ) + ) + notification = notification_recipients[0].notification + + assert PeriodicDataSyncDeactivatedNotificationType.get_notification_title_for_email( + notification, {} + ) == "%(name)s periodic data sync has been deactivated." % { + "name": periodic_data_sync.data_sync.table.name, + } + + assert ( + PeriodicDataSyncDeactivatedNotificationType.get_notification_description_for_email( + notification, {} + ) + == "The periodic data sync failed more than 4 consecutive times" + "and was therefore deactivated." + ) diff --git a/enterprise/web-frontend/modules/baserow_enterprise/components/notifications/PeriodicDataSyncDeactivatedNotification.vue b/enterprise/web-frontend/modules/baserow_enterprise/components/notifications/PeriodicDataSyncDeactivatedNotification.vue new file mode 100644 index 000000000..8b5c0066e --- /dev/null +++ b/enterprise/web-frontend/modules/baserow_enterprise/components/notifications/PeriodicDataSyncDeactivatedNotification.vue @@ -0,0 +1,29 @@ +<template> + <nuxt-link + class="notification-panel__notification-link" + :to="route" + @click.native="markAsReadAndHandleClick" + > + <div class="notification-panel__notification-content-title"> + <i18n path="periodicDataSyncDeactivatedNotification.body" tag="span"> + <template #name> + <strong>{{ notification.data.table_name }}</strong> + </template> + </i18n> + </div> + </nuxt-link> +</template> + +<script> +import notificationContent from '@baserow/modules/core/mixins/notificationContent' + +export default { + name: 'PeriodicDataSyncDeactivatedNotification', + mixins: [notificationContent], + methods: { + handleClick() { + this.$emit('close-panel') + }, + }, +} +</script> diff --git a/enterprise/web-frontend/modules/baserow_enterprise/locales/en.json b/enterprise/web-frontend/modules/baserow_enterprise/locales/en.json index 8705dadab..f382cc5c1 100644 --- a/enterprise/web-frontend/modules/baserow_enterprise/locales/en.json +++ b/enterprise/web-frontend/modules/baserow_enterprise/locales/en.json @@ -466,5 +466,8 @@ "none": "None", "ascending": "Ascending", "descending": "Descending" + }, + "periodicDataSyncDeactivatedNotification": { + "body": "{name} periodic data sync has been deactivated because it failed too many times consecutively." } } diff --git a/enterprise/web-frontend/modules/baserow_enterprise/notificationTypes.js b/enterprise/web-frontend/modules/baserow_enterprise/notificationTypes.js new file mode 100644 index 000000000..70384cd5e --- /dev/null +++ b/enterprise/web-frontend/modules/baserow_enterprise/notificationTypes.js @@ -0,0 +1,29 @@ +import { NotificationType } from '@baserow/modules/core/notificationTypes' + +import PeriodicDataSyncDeactivatedNotification from '@baserow_enterprise/components/notifications/PeriodicDataSyncDeactivatedNotification' +import { PeriodicIntervalFieldsConfigureDataSyncType } from '@baserow_enterprise/configureDataSyncTypes' + +export class PeriodicDataSyncDeactivatedNotificationType extends NotificationType { + static getType() { + return 'periodic_data_sync_deactivated' + } + + getIconComponent() { + return null + } + + getContentComponent() { + return PeriodicDataSyncDeactivatedNotification + } + + getRoute(notificationData) { + return { + name: 'database-table-open-configure-data-sync', + params: { + databaseId: notificationData.database_id, + tableId: notificationData.table_id, + selectedPage: PeriodicIntervalFieldsConfigureDataSyncType.getType(), + }, + } + } +} diff --git a/enterprise/web-frontend/modules/baserow_enterprise/plugin.js b/enterprise/web-frontend/modules/baserow_enterprise/plugin.js index 1fb5b9678..36190bc13 100644 --- a/enterprise/web-frontend/modules/baserow_enterprise/plugin.js +++ b/enterprise/web-frontend/modules/baserow_enterprise/plugin.js @@ -71,6 +71,8 @@ import { VarianceViewAggregationType, MedianViewAggregationType, } from '@baserow/modules/database/viewAggregationTypes' +import { PeriodicDataSyncDeactivatedNotificationType } from '@baserow_enterprise/notificationTypes' + import { FF_AB_SSO, FF_DASHBOARDS, @@ -242,6 +244,11 @@ export default (context) => { new UniqueCountViewAggregationType(context) ) + app.$registry.register( + 'notification', + new PeriodicDataSyncDeactivatedNotificationType(context) + ) + app.$registry.register( 'configureDataSync', new PeriodicIntervalFieldsConfigureDataSyncType(context) diff --git a/web-frontend/modules/database/components/dataSync/ConfigureDataSyncModal.vue b/web-frontend/modules/database/components/dataSync/ConfigureDataSyncModal.vue index 8087d52ec..36ab5c903 100644 --- a/web-frontend/modules/database/components/dataSync/ConfigureDataSyncModal.vue +++ b/web-frontend/modules/database/components/dataSync/ConfigureDataSyncModal.vue @@ -1,5 +1,9 @@ <template> - <Modal :left-sidebar="true" :left-sidebar-scrollable="true"> + <Modal + :left-sidebar="true" + :left-sidebar-scrollable="true" + @hidden="$emit('hidden')" + > <template #sidebar> <div class="modal-sidebar__head"> <div class="modal-sidebar__head-name"> @@ -64,6 +68,15 @@ export default { }, }, methods: { + show(selectedPage, ...args) { + if ( + selectedPage && + this.$registry.exists('configureDataSync', selectedPage) + ) { + this.selectedPage = selectedPage + } + modal.methods.show.bind(this)(...args) + }, setPage(page) { this.selectedPage = page }, diff --git a/web-frontend/modules/database/pages/table.vue b/web-frontend/modules/database/pages/table.vue index 8196d303d..2cb6fa162 100644 --- a/web-frontend/modules/database/pages/table.vue +++ b/web-frontend/modules/database/pages/table.vue @@ -116,7 +116,7 @@ export default { * Prepares all the table, field and view data for the provided database, table and * view id. */ - async asyncData({ store, params, error, app, redirect, route }) { + async asyncData({ store, params, query, error, app, redirect, route }) { // @TODO figure out why the id's aren't converted to an int in the route. const databaseId = parseInt(params.databaseId) const tableId = parseInt(params.tableId) @@ -155,14 +155,11 @@ export default { const viewToUse = getDefaultView(app, store, workspaceId, rowId !== null) if (viewToUse !== undefined) { + params.viewId = viewToUse.id return redirect({ name: route.name, - params: { - databaseId, - tableId, - viewId: viewToUse.id, - rowId: params.rowId, - }, + params, + query, }) } } diff --git a/web-frontend/modules/database/pages/table/configureDataSync.vue b/web-frontend/modules/database/pages/table/configureDataSync.vue new file mode 100644 index 000000000..c9544c590 --- /dev/null +++ b/web-frontend/modules/database/pages/table/configureDataSync.vue @@ -0,0 +1,49 @@ +<template> + <div> + <ConfigureDataSyncModal + ref="configureDataSyncModal" + :database="database" + :table="table" + @hidden="back" + ></ConfigureDataSyncModal> + </div> +</template> + +<script> +import ConfigureDataSyncModal from '@baserow/modules/database/components/dataSync/ConfigureDataSyncModal' + +export default { + components: { ConfigureDataSyncModal }, + props: { + database: { + type: Object, + required: true, + }, + table: { + type: Object, + required: true, + }, + fields: { + type: Array, + required: true, + }, + }, + mounted() { + if (this.table.data_sync === null) { + return this.back() + } + + this.$nextTick(() => { + this.$refs.configureDataSyncModal.show( + this.$route.params.selectedPage || undefined + ) + }) + }, + methods: { + back() { + delete this.$route.params.selectedPage + this.$router.push({ name: 'database-table', params: this.$route.params }) + }, + }, +} +</script> diff --git a/web-frontend/modules/database/routes.js b/web-frontend/modules/database/routes.js index 5d0364791..74ae47315 100644 --- a/web-frontend/modules/database/routes.js +++ b/web-frontend/modules/database/routes.js @@ -28,6 +28,11 @@ export const routes = [ name: 'database-table-open-webhooks', component: path.resolve(__dirname, 'pages/table/webhooks.vue'), }, + { + path: 'configure-data-sync/:selectedPage?', + name: 'database-table-open-configure-data-sync', + component: path.resolve(__dirname, 'pages/table/configureDataSync.vue'), + }, ], }, // These redirect exist because the original api docs path was `/api/docs`, but