Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions backend/src/baserow/api/ai_provider/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,15 @@ class AIProviderModelUpdateSerializer(serializers.Serializer):
)


class AIProviderModelUsageEntrySerializer(serializers.Serializer):
feature_type = serializers.CharField()
count = serializers.IntegerField()


class AIProviderModelUsageSerializer(serializers.Serializer):
usage = AIProviderModelUsageEntrySerializer(many=True)


class AIProviderFeatureModelSerializer(serializers.Serializer):
id = serializers.IntegerField()
model_identifier = serializers.CharField()
Expand Down
6 changes: 6 additions & 0 deletions backend/src/baserow/api/ai_provider/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
AIProviderModelDiscoveryView,
AIProviderModelsTestView,
AIProviderModelsView,
AIProviderModelUsageView,
AIProviderModelView,
AIProvidersView,
AIProviderTypesView,
Expand Down Expand Up @@ -36,4 +37,9 @@
),
path("models/test/", AIProviderModelsTestView.as_view(), name="test_models"),
path("models/<int:model_id>/", AIProviderModelView.as_view(), name="model_item"),
path(
"models/<int:model_id>/usage/",
AIProviderModelUsageView.as_view(),
name="model_usage",
),
]
28 changes: 28 additions & 0 deletions backend/src/baserow/api/ai_provider/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
AIProviderModelsTestRequestSerializer,
AIProviderModelsTestResponseSerializer,
AIProviderModelUpdateSerializer,
AIProviderModelUsageSerializer,
AIProviderModelWriteSerializer,
AIProviderScopeRequestSerializer,
AIProviderTypeSerializer,
Expand Down Expand Up @@ -379,6 +380,33 @@ def delete(self, request, model_id):
return Response(status=HTTP_204_NO_CONTENT)


class AIProviderModelUsageView(APIView):
permission_classes = (IsAuthenticated,)

@extend_schema(
tags=["AI providers"],
operation_id="get_ai_provider_model_usage",
parameters=[AIProviderScopeRequestSerializer],
responses={200: AIProviderModelUsageSerializer},
)
@map_exceptions(EXCEPTION_MAP)
def get(self, request, model_id):
_ensure_feature_enabled()
usage = AIProviderService.get_model_usage(
request.user, model_id, workspace_id=_get_workspace_id(request)
)
return Response(
AIProviderModelUsageSerializer(
{
"usage": [
{"feature_type": feature_type, "count": count}
for feature_type, count in usage.items()
]
}
).data
)


class AIProviderModelsTestView(APIView):
permission_classes = (IsAuthenticated,)

Expand Down
8 changes: 8 additions & 0 deletions backend/src/baserow/config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1863,3 +1863,11 @@ def install_cachalot():
BASEROW_REALTIME_REPLAY_MAX_EVENTS = int(
os.getenv("BASEROW_REALTIME_REPLAY_MAX_EVENTS", 200)
)

REALTIME_REPLAY_RETENTION_HOURS = int(
os.getenv("BASEROW_REALTIME_REPLAY_RETENTION_HOURS", 24)
)
if REALTIME_REPLAY_RETENTION_HOURS <= 0:
raise ImproperlyConfigured(
"BASEROW_REALTIME_REPLAY_RETENTION_HOURS must be a positive integer."
)
7 changes: 6 additions & 1 deletion backend/src/baserow/contrib/database/application_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,8 @@ def export_tables_serialized(
rule_type,
) in field_rules_handler.applicable_rules_with_types:
exported_field_rule = field_rules_handler.export_rule(rule)
serialized_field_rules.append(exported_field_rule)
if exported_field_rule is not None:
serialized_field_rules.append(exported_field_rule)

structure = DatabaseExportSerializedStructure.table(
id=table.id,
Expand Down Expand Up @@ -770,6 +771,10 @@ def _import_field_rules(self, serialized_tables, id_mapping, import_export_confi
field_rules_handler = FieldRuleHandler(table)
serialized_rules = serialized_table["field_rules"]
for serialized_rule in serialized_rules:
# exports made before #6095 may contain null entries for rules
# that were active but invalid at export time.
if not serialized_rule:
continue
field_rules_handler.import_rule(
serialized_rule, id_mapping["database_fields"]
)
Expand Down
6 changes: 5 additions & 1 deletion backend/src/baserow/contrib/database/field_rules/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -596,9 +596,13 @@ def validate_rows_for_rule(

return rule_type.validate_rows(self.table, rule, queryset=queryset)

def export_rule(self, rule: FieldRule):
def export_rule(self, rule: FieldRule) -> dict | None:
"""
Exports a rule.

:param rule: the rule to export.
:return: the serialized rule, or None if the rule is not exportable because
it is disabled or invalid. Callers must not add None to the export payload.
"""

exportable = rule.is_active and rule.is_valid
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("database", "0222_button_field_start_workflow_action"),
]

operations = [
migrations.AddField(
model_name="gridview",
name="group_by_layout",
field=models.CharField(
choices=[("section", "Section"), ("column", "Column")],
db_default="section",
default="section",
max_length=10,
help_text="How grouped rows are presented: sections with a header above "
"each group, or one column per group-by level beside the rows.",
),
),
]
12 changes: 12 additions & 0 deletions backend/src/baserow/contrib/database/views/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,10 @@ class RowHeightSizes(models.TextChoices):
medium = "medium"
large = "large"

class GroupByLayouts(models.TextChoices):
section = "section"
column = "column"

# `field_options` is a very misleading name
# it should probably be more like `fields_with_field_options`
# since this field will return instances of `Field` not of
Expand All @@ -628,6 +632,14 @@ class RowHeightSizes(models.TextChoices):
# Number of frozen (pinned) columns including the primary field. Max defined in
# the serializer.
frozen_column_count = models.PositiveSmallIntegerField(default=1, db_default=1)
group_by_layout = models.CharField(
choices=GroupByLayouts.choices,
default=GroupByLayouts.section,
db_default=GroupByLayouts.section,
max_length=10,
help_text="How grouped rows are presented: sections with a header above each "
"group, or one column per group-by level beside the rows.",
)


class GridViewFieldOptionsManager(models.Manager):
Expand Down
10 changes: 9 additions & 1 deletion backend/src/baserow/contrib/database/views/view_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,17 @@ class GridViewType(ViewType):
has_public_info = True
can_group_by = True
when_shared_publicly_requires_realtime_events = True
allowed_fields = ["row_identifier_type", "row_height_size", "frozen_column_count"]
allowed_fields = [
"row_identifier_type",
"row_height_size",
"frozen_column_count",
"group_by_layout",
]
copyable_view_attributes = [
"row_height_size",
"frozen_column_count",
"row_identifier_type",
"group_by_layout",
]
field_options_allowed_fields = [
"width",
Expand All @@ -102,6 +108,7 @@ class GridViewType(ViewType):
"row_identifier_type",
"row_height_size",
"frozen_column_count",
"group_by_layout",
]
serializer_field_overrides = {
"frozen_column_count": serializers.IntegerField(
Expand Down Expand Up @@ -142,6 +149,7 @@ def export_serialized(
serialized["row_identifier_type"] = grid.row_identifier_type
serialized["row_height_size"] = grid.row_height_size
serialized["frozen_column_count"] = grid.frozen_column_count
serialized["group_by_layout"] = grid.group_by_layout

serialized_field_options = []
for field_option in grid.get_field_options():
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
from django.db.models import Exists, OuterRef

from baserow.contrib.automation.nodes.models import AutomationNode
from baserow.contrib.builder.workflow_actions.models import AIAgentWorkflowAction
from baserow.contrib.integrations.ai.models import AIAgentService
from baserow.core.ai_provider.constants import AI_PROVIDER_FEATURE_AI_AGENT
from baserow.core.ai_provider.registries import AIProviderModelFeatureType
from baserow.core.ai_provider.resolution import ScopedAIProviderState
Expand All @@ -8,6 +13,48 @@
class AIAgentAIProviderModelFeatureType(AIProviderModelFeatureType):
type = AI_PROVIDER_FEATURE_AI_AGENT

def count_model_references(
self,
provider_type: str,
model_identifier: str,
workspace: Workspace | None = None,
) -> int:
"""
Count the AI Agent services selecting one provider model.

One service is owned by an automation node or by builder workflow
actions, and trashing an owner leaves the service row untouched, so a
service counts only while at least one live owner still reaches it. The
owner managers already encode which ancestors count as trashed. A
service whose integration or application is gone belongs to no
workspace, so the joins drop it from both scopes.

:param provider_type: The provider type owning the model.
:param model_identifier: The identifier the services persist.
:param workspace: The workspace to narrow to, or None for the instance
scope, which counts every workspace.
:return: The number of services referencing the model.
"""

live_automation_owner = Exists(
AutomationNode.objects.filter(service_id=OuterRef("pk"))
)
live_builder_owner = Exists(
AIAgentWorkflowAction.objects.filter(
service_id=OuterRef("pk"), page__trashed=False
)
)
queryset = AIAgentService.objects.filter(
ai_generative_ai_type=provider_type,
ai_generative_ai_model=model_identifier,
integration__trashed=False,
integration__application__trashed=False,
integration__application__workspace__trashed=False,
).filter(live_automation_owner | live_builder_owner)
if workspace is not None:
queryset = queryset.filter(integration__application__workspace=workspace)
return queryset.count()

def get_workspace_availability(
self,
workspace: Workspace | None,
Expand Down
21 changes: 21 additions & 0 deletions backend/src/baserow/core/ai_provider/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,27 @@ def delete_model(model: AIProviderModel) -> None:
model.delete()
clear_ai_provider_state_cache()

@staticmethod
def get_model_usage(model: AIProviderModel) -> dict[str, int]:
"""
Count the consumers referencing a model, per feature.

Default-model features are skipped: their RESTRICT foreign key already
blocks the change instead of warning about it.

:param model: The model about to be disabled, deleted or narrowed.
:return: The reference count of every per-consumer feature.
"""

config = model.provider_config
return {
feature_type.type: feature_type.count_model_references(
config.provider_type, model.model_identifier, config.workspace
)
for feature_type in ai_provider_model_feature_type_registry.get_all()
if not feature_type.supports_default_model
}

@staticmethod
def _registered_default_model_feature_types() -> set[str]:
"""
Expand Down
24 changes: 24 additions & 0 deletions backend/src/baserow/core/ai_provider/registries.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from baserow.core.models import Workspace
from baserow.core.registry import Instance, Registry

from .constants import (
Expand All @@ -24,6 +25,29 @@ class AIProviderModelFeatureType(Instance):
supports_default_model = False
required_model_capabilities = (AI_PROVIDER_MODEL_CAPABILITY_TEXT,)

def count_model_references(
self,
provider_type: str,
model_identifier: str,
workspace: Workspace | None = None,
) -> int:
"""
Count the rows this feature stores referencing one provider model.

Consumers persist a provider type and model identifier instead of the
provider row id, so an instance model and a workspace override of the
same type share a count. The result is an upper bound: it may warn an
administrator, but it must never block a change.

:param provider_type: The provider type owning the model.
:param model_identifier: The identifier consumers persist.
:param workspace: The workspace owning the provider, or None for the
instance scope, which counts every workspace.
:return: The number of references this feature holds.
"""

return 0

def get_workspace_availability(self, workspace, state=None) -> dict:
"""Return the client-facing effective availability for this feature.

Expand Down
23 changes: 23 additions & 0 deletions backend/src/baserow/core/ai_provider/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,29 @@ def delete_model(
AIProviderHandler.delete_model(model)
cls._send_updated(user, workspace, True, provider_type, {model_identifier})

@classmethod
def get_model_usage(
cls,
user: AbstractUser,
model_id: int,
workspace_id: int | None = None,
) -> dict[str, int]:
"""
Report what still depends on a model before an admin changes it.

Default-model features are not reported: their selection already refuses
the change with ERROR_AI_PROVIDER_MODEL_IN_USE, which names them.

:param user: The user asking for the counts.
:param model_id: The model about to be disabled, deleted or narrowed.
:param workspace_id: The workspace scope, or None for the instance scope.
:return: The per-consumer-feature counts to warn about.
"""

workspace = cls._check_permissions(user, workspace_id)
model = AIProviderHandler.get_model(model_id, workspace=workspace)
return AIProviderHandler.get_model_usage(model)

@classmethod
def test_models(
cls,
Expand Down
Loading
Loading