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
2 changes: 1 addition & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ dependencies = [
"genson==1.3.0",
"pyotp==2.9.0",
"qrcode==8.2",
"pydantic-ai-slim[anthropic,bedrock,google,groq,mistral,openai]==1.66.0",
"pydantic-ai-slim[anthropic,bedrock,google,groq,mistral,openai]==1.77.0",
"opentelemetry-sdk>=1.20.0",
"netifaces==0.11.0",
"requests-futures>=1.0.2",
Expand Down
27 changes: 24 additions & 3 deletions backend/src/baserow/contrib/database/field_rules/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,28 @@ def create_rule(
self, rule_type_name: str, in_data: dict, primary_key_value: int | None = None
) -> FieldRule:
"""
Creates a rule of a given type.
Creates a rule of a given type after checking rule-type preconditions.

Delegates to `can_create_rule` on the rule type (which may raise if
the required feature or license is unavailable) and then to
`force_create_rule`.

:param rule_type_name: registered rule type name.
:param in_data: a dictionary with all rule params.
:param primary_key_value: (optional) the primary key value for the rule (if
the instance is being restored).
:return: rule instance.
"""

rule_type = self.get_type_handler(rule_type_name)
rule_type.can_create_rule(self.table)
return self.force_create_rule(rule_type_name, in_data, primary_key_value)

def force_create_rule(
self, rule_type_name: str, in_data: dict, primary_key_value: int | None = None
) -> FieldRule:
"""
Creates a rule of a given type without checking rule-type preconditions.

This method creates an instance of a field rule. Field rule type is provided
in `rule_type_name` param. Each field rule type should validate additional
Expand All @@ -190,7 +211,7 @@ def create_rule(
This is used in undo/redo operations, because we want to preserve
rule identification.

:param rule_type_name: registered rule type name .
:param rule_type_name: registered rule type name.
:param in_data: a dictionary with all rule params.
:param primary_key_value: (optional) the primary key value for the rule (if
the instance is being restored).
Expand Down Expand Up @@ -597,4 +618,4 @@ def import_rule(self, rule_data: dict, id_mapping: dict) -> FieldRule:
rule_type = self.get_type_handler(rule_type_name)

prepared_values = rule_type.prepare_values_for_import(rule_data, id_mapping)
return self.create_rule(rule_type_name, prepared_values)
return self.force_create_rule(rule_type_name, prepared_values)
10 changes: 10 additions & 0 deletions backend/src/baserow/contrib/database/field_rules/registries.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,16 @@ def prepare_values_for_import(self, rule_data: dict, id_mapping: dict) -> dict:

return rule_data

def can_create_rule(self, table: Table) -> None:
"""
Called before creating a new rule to check if the rule can be created.
Raises an exception if the rule cannot be created (e.g. missing license).
Does nothing by default.
:param table: the table for which the rule is being created
"""

def prepare_values_for_create(self, table, in_data: dict) -> dict:
"""
Called before creating a new rule. Resulting dict should contain
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
)
from baserow.contrib.database.api.utils import extract_field_ids_from_list
from baserow.contrib.database.fields.exceptions import (
FieldDataConstraintException,
FieldDoesNotExist,
IncompatibleField,
)
Expand Down Expand Up @@ -2143,6 +2144,10 @@ def dispatch_data(
raise ServiceImproperlyConfiguredDispatchException(
f"The row with id {row_id} does not exist."
) from exc
except FieldDataConstraintException as exc:
raise InvalidContextContentDispatchException(
f"The row with id {row_id} violates a field constraint."
) from exc
else:
try:
(row,) = CreateRowsActionType.do(
Expand All @@ -2156,6 +2161,11 @@ def dispatch_data(
f"Cannot create rows in table {table.id} because "
"it has a data sync."
) from exc
except FieldDataConstraintException as exc:
raise InvalidContextContentDispatchException(
f"Cannot create rows in table {table.id} because "
"it violates a field constraint."
) from exc

return {
"data": row,
Expand Down
102 changes: 74 additions & 28 deletions backend/src/baserow/core/generative_ai/generative_ai_model_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

from django.conf import settings

from loguru import logger

from baserow.core.models import Workspace

from .registries import GenerativeAIModelType
Expand Down Expand Up @@ -124,23 +126,76 @@ def get_base_url(
".xlsx",
".xls",
}
_MAX_EMBED_PAYLOAD_BYTES = 45 * 1024 * 1024 # 50 MB minus some headroom
# https://developers.openai.com/api/docs/guides/file-inputs
_MAX_EMBED_PAYLOAD_BYTES = 45 * 1024 * 1024 # 50 MB minus headroom
_MAX_EMBEDS_PER_REQUEST = 500
# Below this limit, uploadable files are sent inline.
_INLINE_UPLOAD_THRESHOLD_BYTES = 10 * 1024 # 10 KB

def _get_max_upload_bytes(self) -> int:
return (
min(512, settings.BASEROW_OPENAI_UPLOADED_FILE_SIZE_LIMIT_MB) * 1024 * 1024
)

def _can_embed(self, file_size: int, embed_count: int, embed_payload: int) -> bool:
return (
embed_count < self._MAX_EMBEDS_PER_REQUEST
and embed_payload + file_size <= self._MAX_EMBED_PAYLOAD_BYTES
)

@staticmethod
def _embed(ai_file: "AIFile", data: bytes) -> None:
from pydantic_ai import BinaryContent

ai_file.content = BinaryContent(
data=data,
media_type=ai_file.mime_type,
identifier=ai_file.original_name,
)

@staticmethod
def _inline_text(ai_file: "AIFile", data: bytes) -> bool:
"""Try to inline file content as TextContent. Returns False if the
content is not valid UTF-8."""

from pydantic_ai import TextContent

try:
text = data.decode("utf-8")
except (UnicodeDecodeError, ValueError):
return False
ai_file.content = TextContent(
content=(
f"[Content of file '{ai_file.original_name}']\n{text}\n[End of file]"
),
metadata={"source": ai_file.original_name},
)
return True

def _upload(
self,
ai_file: "AIFile",
data: bytes,
workspace: Optional[Workspace] = None,
settings_override: Optional[dict[str, Any]] = None,
) -> None:
from pydantic_ai import UploadedFile

file_id = self._upload_file(ai_file.name, data, workspace, settings_override)
ai_file.provider_file_id = file_id
ai_file.content = UploadedFile(
file_id=file_id,
provider_name="openai",
media_type=ai_file.mime_type,
identifier=ai_file.original_name,
)

def prepare_files(
self,
files: list[AIFile],
workspace: Optional[Workspace] = None,
settings_override: Optional[dict[str, Any]] = None,
) -> list[AIFile]:
from loguru import logger
from pydantic_ai import BinaryContent, UploadedFile

embed_payload = 0
embed_count = 0
max_upload = self._get_max_upload_bytes()
Expand All @@ -151,38 +206,29 @@ def prepare_files(

try:
if ext in self._EMBEDDABLE_EXTENSIONS:
if (
embed_count >= self._MAX_EMBEDS_PER_REQUEST
or embed_payload + ai_file.size > self._MAX_EMBED_PAYLOAD_BYTES
):
if not self._can_embed(ai_file.size, embed_count, embed_payload):
continue
data = ai_file.read_content()
ai_file.content = BinaryContent(
data=data,
media_type=ai_file.mime_type,
identifier=ai_file.original_name,
)
self._embed(ai_file, ai_file.read_content())
embed_payload += ai_file.size
embed_count += 1

elif ext in self._UPLOADABLE_EXTENSIONS:
if ai_file.size > max_upload:
continue
data = ai_file.read_content()
file_id = self._upload_file(
ai_file.name, data, workspace, settings_override
)
ai_file.provider_file_id = file_id
ai_file.content = UploadedFile(
file_id=file_id,
provider_name="openai",
media_type=ai_file.mime_type,
identifier=ai_file.original_name,
)
except Exception:
logger.warning(
f"Skipping file {ai_file.name}: failed to read or upload."
)

if (
ai_file.size <= self._INLINE_UPLOAD_THRESHOLD_BYTES
and self._can_embed(ai_file.size, embed_count, embed_payload)
):
if not self._inline_text(ai_file, data):
self._embed(ai_file, data)
embed_payload += ai_file.size
embed_count += 1
else:
self._upload(ai_file, data, workspace, settings_override)
except Exception as exc:
logger.warning(f"Skipping file {ai_file.name}: {exc}")
continue

return [f for f in files if f.content is not None]
Expand Down
2 changes: 1 addition & 1 deletion backend/src/baserow/core/generative_ai/registries.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ def _build_user_prompt(
if content:
prompt = (
f"{prompt}\n\n"
"The contents of the attached files are included below. "
"The following file contents are provided for context. "
"Use them to answer the prompt above."
)
return [prompt] + content
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@
NotificationWorkflowActionType,
UpdateRowWorkflowActionType,
)
from baserow.contrib.database.fields.field_constraints import (
TextTypeUniqueWithEmptyConstraint,
)
from baserow.contrib.database.fields.handler import FieldHandler
from baserow.contrib.database.rows.handler import RowHandler
from baserow.contrib.database.table.handler import TableHandler
from baserow.contrib.integrations.local_baserow.service_types import (
Expand Down Expand Up @@ -638,6 +642,58 @@ def test_dispatch_local_baserow_create_row_workflow_action(api_client, data_fixt
assert animal_field.name not in response_json


@pytest.mark.django_db
def test_dispatch_local_baserow_create_row_workflow_action_field_constraint(
api_client, data_fixture
):
user, token = data_fixture.create_user_and_token()
table = data_fixture.create_database_table(user=user)

# Create a field that has a constraint, e.g. unique
fruit_field = FieldHandler().create_field(
user=user,
table=table,
type_name="text",
name="Unique fruit",
field_constraints=[
{"type_name": TextTypeUniqueWithEmptyConstraint.constraint_name}
],
)

model = table.get_model()
model.objects.create(**{f"field_{fruit_field.id}": "Apple"})

builder = data_fixture.create_builder_application(user=user)
page = data_fixture.create_builder_page(user=user, builder=builder)
element = data_fixture.create_builder_button_element(page=page)
workflow_action = data_fixture.create_local_baserow_create_row_workflow_action(
page=page, element=element, event=EventTypes.CLICK, user=user
)
service = workflow_action.service.specific
service.table = table
service.field_mappings.create(field=fruit_field, value="'Apple'")
service.save()

url = reverse(
"api:builder:workflow_action:dispatch",
kwargs={"workflow_action_id": workflow_action.id},
)
response = api_client.post(
url,
{},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)

assert response.status_code == HTTP_400_BAD_REQUEST
response_json = response.json()
assert response_json["error"] == "ERROR_SERVICE_INVALID_DISPATCH_CONTEXT_CONTENT"
assert (
response_json["detail"]
== f"Cannot create rows in table {table.id} because it violates a field constraint."
)


@pytest.mark.django_db
def test_dispatch_local_baserow_update_row_workflow_action(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
Expand Down Expand Up @@ -696,6 +752,61 @@ def test_dispatch_local_baserow_update_row_workflow_action(api_client, data_fixt
assert animal_field.name not in response_json


@pytest.mark.django_db
def test_dispatch_local_baserow_update_row_workflow_action_field_constraint(
api_client, data_fixture
):
user, token = data_fixture.create_user_and_token()
table = data_fixture.create_database_table(user=user)

# Create a field that has a constraint, e.g. unique
fruit_field = FieldHandler().create_field(
user=user,
table=table,
type_name="text",
name="Unique fruit",
field_constraints=[
{"type_name": TextTypeUniqueWithEmptyConstraint.constraint_name}
],
)

model = table.get_model()
model.objects.create(**{f"field_{fruit_field.id}": "Apple"})
# Create another row, which we'll update to 'Apple' to simulate a unique constraint
second_row = model.objects.create(**{f"field_{fruit_field.id}": "Banana"})

builder = data_fixture.create_builder_application(user=user)
page = data_fixture.create_builder_page(user=user, builder=builder)
element = data_fixture.create_builder_button_element(page=page)
workflow_action = data_fixture.create_local_baserow_create_row_workflow_action(
page=page, element=element, event=EventTypes.CLICK, user=user
)
service = workflow_action.service.specific
service.table = table
service.row_id = f"'{second_row.id}'"
service.field_mappings.create(field=fruit_field, value="'Apple'")
service.save()

url = reverse(
"api:builder:workflow_action:dispatch",
kwargs={"workflow_action_id": workflow_action.id},
)
response = api_client.post(
url,
{},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)

assert response.status_code == HTTP_400_BAD_REQUEST
response_json = response.json()
assert response_json["error"] == "ERROR_SERVICE_INVALID_DISPATCH_CONTEXT_CONTENT"
assert (
response_json["detail"]
== f"The row with id {second_row.id} violates a field constraint."
)


@pytest.mark.django_db
def test_dispatch_local_baserow_upsert_row_workflow_action_with_current_record(
api_client, data_fixture
Expand Down
Loading
Loading