diff --git a/backend/src/baserow/contrib/database/fields/dependencies/update_collector.py b/backend/src/baserow/contrib/database/fields/dependencies/update_collector.py index ce6635ebd7..068d7d9233 100644 --- a/backend/src/baserow/contrib/database/fields/dependencies/update_collector.py +++ b/backend/src/baserow/contrib/database/fields/dependencies/update_collector.py @@ -1,3 +1,4 @@ +import dataclasses from collections import defaultdict from typing import Dict, List, Optional, Set, Tuple, cast @@ -16,6 +17,20 @@ StartingRowIdsType = Optional[List[int]] +@dataclasses.dataclass +class DependencyContext: + """ + DependencyContext is used to pass additional dependency-related information + to callbacks. + """ + + # The depth of the dependency chain from the starting + # field to the field parameter. 0 means the field is a direct dependency of + # the updated row's field. 1 means the field depends on a field which depends + # on the updated row's field, etc. + depth: int = 0 + + class PathBasedUpdateStatementCollector: def __init__( self, diff --git a/backend/src/baserow/contrib/database/fields/field_types.py b/backend/src/baserow/contrib/database/fields/field_types.py index 8bd5f477f0..5f00564a82 100755 --- a/backend/src/baserow/contrib/database/fields/field_types.py +++ b/backend/src/baserow/contrib/database/fields/field_types.py @@ -175,7 +175,7 @@ from .dependencies.handler import FieldDependants, FieldDependencyHandler from .dependencies.models import FieldDependency from .dependencies.types import FieldDependencies -from .dependencies.update_collector import FieldUpdateCollector +from .dependencies.update_collector import DependencyContext, FieldUpdateCollector from .exceptions import ( AllProvidedCollaboratorIdsMustBeValidUsers, AllProvidedMultipleSelectValuesMustBeSelectOption, @@ -3582,6 +3582,7 @@ def row_of_dependency_updated( update_collector: FieldUpdateCollector, field_cache: "FieldCache", via_path_to_starting_table: List["LinkRowField"], + dependency_context: DependencyContext, ): update_collector.add_field_which_has_changed( field, via_path_to_starting_table, send_field_updated_signal=False @@ -3592,6 +3593,7 @@ def row_of_dependency_updated( update_collector, field_cache, via_path_to_starting_table, + dependency_context, ) def field_dependency_updated( @@ -5697,6 +5699,7 @@ def row_of_dependency_updated( update_collector: FieldUpdateCollector, field_cache: "FieldCache", via_path_to_starting_table: Optional[List[LinkRowField]], + dependency_context: DependencyContext, ): self._update_field_values( field, update_collector, field_cache, via_path_to_starting_table @@ -5708,6 +5711,7 @@ def row_of_dependency_updated( update_collector, field_cache, via_path_to_starting_table, + dependency_context, ) def _update_field_values( diff --git a/backend/src/baserow/contrib/database/fields/registries.py b/backend/src/baserow/contrib/database/fields/registries.py index b9168b38da..726147132f 100644 --- a/backend/src/baserow/contrib/database/fields/registries.py +++ b/backend/src/baserow/contrib/database/fields/registries.py @@ -81,6 +81,7 @@ from baserow.contrib.database.fields.dependencies.handler import FieldDependants from baserow.contrib.database.fields.dependencies.types import FieldDependencies from baserow.contrib.database.fields.dependencies.update_collector import ( + DependencyContext, FieldUpdateCollector, ) from baserow.contrib.database.fields.field_cache import FieldCache @@ -1446,6 +1447,7 @@ def row_of_dependency_created( update_collector: "FieldUpdateCollector", field_cache: "FieldCache", via_path_to_starting_table: Optional[List[LinkRowField]], + dependency_context: "DependencyContext", ): """ Called when a row is created in a dependency field (a field that the field @@ -1469,6 +1471,7 @@ def row_of_dependency_created( update_collector, field_cache, via_path_to_starting_table, + dependency_context, ) def row_of_dependency_updated( @@ -1478,6 +1481,7 @@ def row_of_dependency_updated( update_collector: "FieldUpdateCollector", field_cache: "FieldCache", via_path_to_starting_table: List["LinkRowField"], + dependency_context: "DependencyContext", ): """ Called when a row or rows are updated in a dependency field (a field that the @@ -1495,6 +1499,8 @@ def row_of_dependency_updated( :param field_cache: An optional field cache to be used when fetching fields. :param via_path_to_starting_table: A list of link row fields if any leading back to the starting table where the first row was changed. + :param dependency_context: A DependencyContext object containing additional + information about the dependency and the triggering change. """ def row_of_dependency_deleted( @@ -1504,6 +1510,7 @@ def row_of_dependency_deleted( update_collector: "FieldUpdateCollector", field_cache: "FieldCache", via_path_to_starting_table: Optional[List[LinkRowField]], + dependency_context: "DependencyContext", ): """ Called when a row is deleted in a dependency field (a field that the @@ -1527,6 +1534,7 @@ def row_of_dependency_deleted( update_collector, field_cache, via_path_to_starting_table, + dependency_context, ) def field_dependency_created( diff --git a/backend/src/baserow/contrib/database/rows/handler.py b/backend/src/baserow/contrib/database/rows/handler.py index 128aadbc33..ade2eeac43 100644 --- a/backend/src/baserow/contrib/database/rows/handler.py +++ b/backend/src/baserow/contrib/database/rows/handler.py @@ -34,6 +34,7 @@ from baserow.contrib.database.field_rules.handlers import FieldRuleHandler from baserow.contrib.database.fields.dependencies.handler import FieldDependencyHandler from baserow.contrib.database.fields.dependencies.update_collector import ( + DependencyContext, FieldUpdateCollector, ) from baserow.contrib.database.fields.exceptions import ( @@ -1154,7 +1155,10 @@ def update_dependencies_of_rows_updated( deleted_m2m_rels_per_link_field=deleted_m2m_rels_per_link_field, ) updated_fields = [] - for dependant_fields_group in all_dependent_fields_grouped_by_depth: + for depth, dependant_fields_group in enumerate( + all_dependent_fields_grouped_by_depth + ): + dependency_context = DependencyContext(depth=depth) for ( dependant_field, dependant_field_type, @@ -1167,6 +1171,7 @@ def update_dependencies_of_rows_updated( update_collector, field_cache, path_to_starting_table, + dependency_context, ) update_collector.apply_updates_and_get_updated_fields( field_cache, skip_search_updates @@ -1486,19 +1491,24 @@ def update_dependencies_of_rows_created( ) ) - for dependant_fields_group in all_dependent_fields_grouped_by_depth: + for depth, dependant_fields_group in enumerate( + all_dependent_fields_grouped_by_depth + ): + dependency_context = DependencyContext(depth=depth) for ( dependant_field, dependant_field_type, path_to_starting_table, ) in dependant_fields_group: dependant_fields.append(dependant_field) + dependant_field_type.row_of_dependency_created( dependant_field, created_rows, update_collector, field_cache, path_to_starting_table, + dependency_context, ) update_collector.apply_updates_and_get_updated_fields(field_cache) return fields, dependant_fields @@ -2754,7 +2764,10 @@ def update_dependencies_of_rows_deleted(self, table, row, model): ) ) - for dependent_fields_level in all_dependent_fields_grouped_by_level: + for depth, dependent_fields_level in enumerate( + all_dependent_fields_grouped_by_level + ): + dependency_context = DependencyContext(depth=depth) for ( dependant_field, dependant_field_type, @@ -2768,6 +2781,7 @@ def update_dependencies_of_rows_deleted(self, table, row, model): update_collector, field_cache, path_to_starting_table, + dependency_context, ) update_collector.apply_updates_and_get_updated_fields(field_cache) @@ -2910,7 +2924,10 @@ def force_delete_rows( ) ) - for dependent_fields_level in all_dependent_fields_grouped_by_level: + for depth, dependent_fields_level in enumerate( + all_dependent_fields_grouped_by_level + ): + dependency_context = DependencyContext(depth=depth) for ( table_id, dependant_field, @@ -2924,6 +2941,7 @@ def force_delete_rows( update_collector, field_cache, path_to_starting_table, + dependency_context, ) update_collector.apply_updates_and_get_updated_fields(field_cache) diff --git a/backend/src/baserow/test_utils/pytest_conftest.py b/backend/src/baserow/test_utils/pytest_conftest.py index 5c30e60dfe..a3be16583c 100755 --- a/backend/src/baserow/test_utils/pytest_conftest.py +++ b/backend/src/baserow/test_utils/pytest_conftest.py @@ -60,7 +60,10 @@ def fake(): # This solution is taken from: https://bit.ly/3UJ90co @pytest.fixture(scope="session") def async_event_loop(): - loop = asyncio.get_event_loop() + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = asyncio.new_event_loop() yield loop loop.close() diff --git a/backend/tests/baserow/contrib/database/field/test_formula_field_type.py b/backend/tests/baserow/contrib/database/field/test_formula_field_type.py index 6c898845d2..8aa8a9ceda 100644 --- a/backend/tests/baserow/contrib/database/field/test_formula_field_type.py +++ b/backend/tests/baserow/contrib/database/field/test_formula_field_type.py @@ -12,6 +12,7 @@ from rest_framework.status import HTTP_200_OK, HTTP_204_NO_CONTENT from baserow.contrib.database.fields.dependencies.update_collector import ( + DependencyContext, FieldUpdateCollector, ) from baserow.contrib.database.fields.field_cache import FieldCache @@ -864,23 +865,24 @@ def test_row_dependency_update_functions_do_one_row_updates_for_same_table( field_cache = FieldCache() field_cache.cache_model(table_model) + dependency_context = DependencyContext(depth=0) formula_field_type.row_of_dependency_updated( - formula_field, row, update_collector, field_cache, None + formula_field, row, update_collector, field_cache, None, dependency_context ) formula_field_type.row_of_dependency_updated( - formula_field, row, update_collector, field_cache, [] + formula_field, row, update_collector, field_cache, [], dependency_context ) formula_field_type.row_of_dependency_created( - formula_field, row, update_collector, field_cache, None + formula_field, row, update_collector, field_cache, None, dependency_context ) formula_field_type.row_of_dependency_created( - formula_field, row, update_collector, field_cache, [] + formula_field, row, update_collector, field_cache, [], dependency_context ) formula_field_type.row_of_dependency_deleted( - formula_field, row, update_collector, field_cache, None + formula_field, row, update_collector, field_cache, None, dependency_context ) formula_field_type.row_of_dependency_deleted( - formula_field, row, update_collector, field_cache, [] + formula_field, row, update_collector, field_cache, [], dependency_context ) # Does one update to update the last_system_or_user_row_update_on column with django_assert_num_queries(1): diff --git a/changelog/entries/unreleased/feature/4115_ai_field_autoupdate.json b/changelog/entries/unreleased/feature/4115_ai_field_autoupdate.json new file mode 100644 index 0000000000..b1466f51a4 --- /dev/null +++ b/changelog/entries/unreleased/feature/4115_ai_field_autoupdate.json @@ -0,0 +1,8 @@ +{ + "type": "feature", + "message": "AI field auto-update", + "domain": "database", + "issue_number": 4115, + "bullet_points": [], + "created_at": "2025-11-04" +} \ No newline at end of file diff --git a/enterprise/backend/src/baserow_enterprise/api/assistant/views.py b/enterprise/backend/src/baserow_enterprise/api/assistant/views.py index a97e1ee2e4..f693c5dd60 100644 --- a/enterprise/backend/src/baserow_enterprise/api/assistant/views.py +++ b/enterprise/backend/src/baserow_enterprise/api/assistant/views.py @@ -4,7 +4,6 @@ from django.http import StreamingHttpResponse -from baserow_premium.license.handler import LicenseHandler from drf_spectacular.openapi import OpenApiParameter, OpenApiTypes from drf_spectacular.utils import OpenApiResponse, extend_schema from loguru import logger @@ -39,7 +38,6 @@ HumanMessage, UIContext, ) -from baserow_enterprise.features import ASSISTANT from .errors import ( ERROR_ASSISTANT_CHAT_DOES_NOT_EXIST, @@ -62,7 +60,6 @@ class AssistantChatsView(APIView): operation_id="list_assistant_chats", description=( "List all AI assistant chats for the current user in the specified workspace." - "\n\nThis is a **advanced/enterprise** feature." ), parameters=[ OpenApiParameter( @@ -104,10 +101,6 @@ def get(self, request: Request, query_params) -> Response: workspace_id = query_params["workspace_id"] workspace = CoreHandler().get_workspace(workspace_id) - LicenseHandler.raise_if_user_doesnt_have_feature( - ASSISTANT, request.user, workspace - ) - CoreHandler().check_permissions( request.user, ChatAssistantChatOperationType.type, @@ -132,7 +125,6 @@ class AssistantChatView(APIView): operation_id="send_message_to_assistant_chat", description=( "Send a message to the specified AI assistant chat and stream back the response.\n\n" - "This is an **advanced/enterprise** feature." ), request=AssistantMessageRequestSerializer, responses={ @@ -157,9 +149,6 @@ def post(self, request: Request, chat_uuid: str, data) -> StreamingHttpResponse: ui_context = UIContext.from_validate_request(request, data["ui_context"]) workspace_id = ui_context.workspace.id workspace = CoreHandler().get_workspace(workspace_id) - LicenseHandler.raise_if_user_doesnt_have_feature( - ASSISTANT, request.user, workspace - ) CoreHandler().check_permissions( request.user, ChatAssistantChatOperationType.type, @@ -216,10 +205,7 @@ def _stream_assistant_message(self, message: AssistantMessageUnion) -> str: @extend_schema( tags=["AI Assistant"], operation_id="list_assistant_chat_messages", - description=( - "List all messages in the specified AI assistant chat.\n\n" - "This is an **advanced/enterprise** feature." - ), + description=("List all messages in the specified AI assistant chat.\n\n"), responses={ 200: AssistantChatMessagesSerializer, 400: get_error_schema(["ERROR_USER_NOT_IN_GROUP"]), @@ -239,9 +225,6 @@ def get(self, request: Request, chat_uuid: str) -> Response: chat = handler.get_chat(request.user, chat_uuid) workspace = chat.workspace - LicenseHandler.raise_if_user_doesnt_have_feature( - ASSISTANT, request.user, workspace - ) CoreHandler().check_permissions( request.user, ChatAssistantChatOperationType.type, @@ -263,7 +246,6 @@ class AssistantChatMessageFeedbackView(APIView): operation_id="submit_assistant_message_feedback", description=( "Provide sentiment and feedback for the given AI assistant chat message.\n\n" - "This is an **advanced/enterprise** feature." ), responses={ 200: None, @@ -286,10 +268,6 @@ def put(self, request: Request, message_id: int, data) -> Response: handler = AssistantHandler() message = handler.get_chat_message_by_id(request.user, message_id) - LicenseHandler.raise_if_user_doesnt_have_feature( - ASSISTANT, request.user, message.chat.workspace - ) - try: prediction: AssistantChatPrediction = message.prediction except AttributeError: diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/database/tools.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/database/tools.py index 2e392170a6..dc73d12066 100644 --- a/enterprise/backend/src/baserow_enterprise/assistant/tools/database/tools.py +++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/database/tools.py @@ -591,6 +591,7 @@ def create_views( table_id=table.id, view_id=created_views[0]["id"], view_name=created_views[0]["name"], + view_type=created_views[0]["type"], ) ) diff --git a/enterprise/backend/src/baserow_enterprise/assistant/types.py b/enterprise/backend/src/baserow_enterprise/assistant/types.py index 4a444e4c15..226730b961 100644 --- a/enterprise/backend/src/baserow_enterprise/assistant/types.py +++ b/enterprise/backend/src/baserow_enterprise/assistant/types.py @@ -197,6 +197,7 @@ class ViewNavigationType(BaseModel): table_id: int view_id: int view_name: str + view_type: str def to_localized_string(self): return _("view %(view_name)s") % {"view_name": self.view_name} diff --git a/enterprise/backend/src/baserow_enterprise/features.py b/enterprise/backend/src/baserow_enterprise/features.py index bbac4dd1fc..9dc59be75d 100644 --- a/enterprise/backend/src/baserow_enterprise/features.py +++ b/enterprise/backend/src/baserow_enterprise/features.py @@ -9,7 +9,6 @@ DATA_SYNC = "data_sync" ADVANCED_WEBHOOKS = "advanced_webhooks" FIELD_LEVEL_PERMISSIONS = "field_level_permissions" -ASSISTANT = "assistant" BUILDER_SSO = "application_user_sso" BUILDER_NO_BRANDING = "application_no_branding" diff --git a/enterprise/backend/src/baserow_enterprise/license_types.py b/enterprise/backend/src/baserow_enterprise/license_types.py index 7ce168a4e3..449f69b860 100755 --- a/enterprise/backend/src/baserow_enterprise/license_types.py +++ b/enterprise/backend/src/baserow_enterprise/license_types.py @@ -7,7 +7,6 @@ from baserow.core.models import Workspace from baserow_enterprise.features import ( ADVANCED_WEBHOOKS, - ASSISTANT, AUDIT_LOG, BUILDER_CUSTOM_CODE, BUILDER_FILE_INPUT, @@ -33,7 +32,6 @@ RBAC, TEAMS, AUDIT_LOG, - ASSISTANT, # database DATA_SYNC, ADVANCED_WEBHOOKS, diff --git a/enterprise/backend/tests/baserow_enterprise_tests/api/assistant/test_assistant_views.py b/enterprise/backend/tests/baserow_enterprise_tests/api/assistant/test_assistant_views.py index 668e73d0ad..f5705824c6 100644 --- a/enterprise/backend/tests/baserow_enterprise_tests/api/assistant/test_assistant_views.py +++ b/enterprise/backend/tests/baserow_enterprise_tests/api/assistant/test_assistant_views.py @@ -62,23 +62,6 @@ def test_cannot_list_assistant_chats_without_valid_workspace( assert rsp.json()["error"] == "ERROR_USER_NOT_IN_GROUP" -@pytest.mark.django_db -@override_settings(DEBUG=True) -def test_cannot_list_assistant_chats_without_license( - api_client, enterprise_data_fixture -): - user, token = enterprise_data_fixture.create_user_and_token() - workspace = enterprise_data_fixture.create_workspace(user=user) - - rsp = api_client.get( - reverse("assistant:list") + f"?workspace_id={workspace.id}", - format="json", - HTTP_AUTHORIZATION=f"JWT {token}", - ) - assert rsp.status_code == 402 - assert rsp.json()["error"] == "ERROR_FEATURE_NOT_AVAILABLE" - - @pytest.mark.django_db @override_settings(DEBUG=True) def test_list_assistant_chats(api_client, enterprise_data_fixture): @@ -181,28 +164,6 @@ def test_cannot_send_message_without_valid_workspace( assert rsp.json()["error"] == "ERROR_USER_NOT_IN_GROUP" -@pytest.mark.django_db -@override_settings(DEBUG=True) -def test_cannot_send_message_without_license(api_client, enterprise_data_fixture): - """Test that sending messages requires an enterprise license""" - - user, token = enterprise_data_fixture.create_user_and_token() - workspace = enterprise_data_fixture.create_workspace(user=user) - chat_uuid = str(uuid4()) - - rsp = api_client.post( - reverse("assistant:chat_messages", kwargs={"chat_uuid": chat_uuid}), - data={ - "content": "Hello AI", - "ui_context": {"workspace": {"id": workspace.id, "name": workspace.name}}, - }, - format="json", - HTTP_AUTHORIZATION=f"JWT {token}", - ) - assert rsp.status_code == 402 - assert rsp.json()["error"] == "ERROR_FEATURE_NOT_AVAILABLE" - - @pytest.mark.django_db() @override_settings(DEBUG=True) @patch("baserow_enterprise.assistant.handler.Assistant") @@ -403,30 +364,6 @@ def test_cannot_get_messages_without_valid_chat(api_client, enterprise_data_fixt assert rsp.json()["error"] == "ERROR_ASSISTANT_CHAT_DOES_NOT_EXIST" -@pytest.mark.django_db -@override_settings(DEBUG=True) -def test_cannot_get_messages_without_license(api_client, enterprise_data_fixture): - """Test that getting messages requires an enterprise license""" - - user, token = enterprise_data_fixture.create_user_and_token() - workspace = enterprise_data_fixture.create_workspace(user=user) - - # Create a chat - chat = AssistantChat.objects.create( - user=user, workspace=workspace, title="Test Chat" - ) - - rsp = api_client.get( - reverse( - "assistant:chat_messages", - kwargs={"chat_uuid": str(chat.uuid)}, - ), - HTTP_AUTHORIZATION=f"JWT {token}", - ) - assert rsp.status_code == 402 - assert rsp.json()["error"] == "ERROR_FEATURE_NOT_AVAILABLE" - - @pytest.mark.django_db @override_settings(DEBUG=True) def test_cannot_get_messages_from_another_users_chat( @@ -1930,43 +1867,6 @@ def test_cannot_submit_feedback_for_another_users_message( assert rsp.json()["error"] == "ERROR_ASSISTANT_CHAT_DOES_NOT_EXIST" -@pytest.mark.django_db -@override_settings(DEBUG=True) -def test_cannot_submit_feedback_without_license(api_client, enterprise_data_fixture): - """Test that submitting feedback requires an enterprise license""" - - user, token = enterprise_data_fixture.create_user_and_token() - workspace = enterprise_data_fixture.create_workspace(user=user) - # Note: NOT enabling enterprise license - - # Create chat and messages - chat = AssistantChat.objects.create( - user=user, workspace=workspace, title="Test Chat" - ) - human_message = AssistantChatMessage.objects.create( - chat=chat, role=AssistantChatMessage.Role.HUMAN, content="Question" - ) - ai_message = AssistantChatMessage.objects.create( - chat=chat, role=AssistantChatMessage.Role.AI, content="Answer" - ) - AssistantChatPrediction.objects.create( - human_message=human_message, - ai_response=ai_message, - prediction={"reasoning": "test"}, - ) - - # Try to submit feedback without license - rsp = api_client.put( - reverse("assistant:message_feedback", kwargs={"message_id": ai_message.id}), - data={"sentiment": "LIKE"}, - format="json", - HTTP_AUTHORIZATION=f"JWT {token}", - ) - - assert rsp.status_code == 402 - assert rsp.json()["error"] == "ERROR_FEATURE_NOT_AVAILABLE" - - @pytest.mark.django_db @override_settings(DEBUG=True) def test_submit_feedback_validates_sentiment_choice( diff --git a/enterprise/web-frontend/modules/baserow_enterprise/assets/videos/kuma.mp4 b/enterprise/web-frontend/modules/baserow_enterprise/assets/videos/kuma.mp4 index cde840e5b5..f28a78ec35 100644 Binary files a/enterprise/web-frontend/modules/baserow_enterprise/assets/videos/kuma.mp4 and b/enterprise/web-frontend/modules/baserow_enterprise/assets/videos/kuma.mp4 differ diff --git a/enterprise/web-frontend/modules/baserow_enterprise/components/EnterpriseFeatures.vue b/enterprise/web-frontend/modules/baserow_enterprise/components/EnterpriseFeatures.vue index f18d4c71f6..8cd7efa1d3 100644 --- a/enterprise/web-frontend/modules/baserow_enterprise/components/EnterpriseFeatures.vue +++ b/enterprise/web-frontend/modules/baserow_enterprise/components/EnterpriseFeatures.vue @@ -51,13 +51,6 @@ {{ $t('enterpriseFeatures.fieldLevelPermissions') }} -
  • - - {{ $t('enterpriseFeatures.assistant') }} -
  • table.id === newLocation.table_id - ) && - (!newLocation.view_id || - store.getters['view/get'](newLocation.view_id) !== undefined) + database.tables.find((table) => table.id === newLocation.table_id) + const viewLoaded = store.getters['view/get'](newLocation.view_id) + return ( + tableLoaded && + (!isCurrentlyOnTable || !newLocation.view_id || viewLoaded) ) }).then(() => { router.push({ diff --git a/enterprise/web-frontend/modules/baserow_enterprise/components/assistant/AssistantSidebarItem.vue b/enterprise/web-frontend/modules/baserow_enterprise/components/assistant/AssistantSidebarItem.vue index d9d31252e9..eacef2afa0 100644 --- a/enterprise/web-frontend/modules/baserow_enterprise/components/assistant/AssistantSidebarItem.vue +++ b/enterprise/web-frontend/modules/baserow_enterprise/components/assistant/AssistantSidebarItem.vue @@ -1,27 +1,8 @@