Skip to content

Commit 7c737c7

Browse files
feat: show all generation jobs in AIField in modal (baserow#4265)
* Show jobs for AIField in modal * Use list_job endpoint * Remove console.log --------- Co-authored-by: Davide Silvestri <davide@baserow.io>
1 parent 555bd24 commit 7c737c7

File tree

26 files changed

+952
-230
lines changed

26 files changed

+952
-230
lines changed

backend/src/baserow/api/jobs/serializers.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from django.utils.functional import lazy
22

3+
from drf_spectacular.extensions import OpenApiSerializerExtension
4+
from drf_spectacular.plumbing import force_instance
35
from drf_spectacular.types import OpenApiTypes
46
from drf_spectacular.utils import extend_schema_field
57
from rest_framework import serializers
@@ -42,6 +44,8 @@ class Meta:
4244
"progress_percentage",
4345
"state",
4446
"human_readable_error",
47+
"created_on",
48+
"updated_on",
4549
)
4650
extra_kwargs = {
4751
"id": {"read_only": True},
@@ -63,9 +67,31 @@ class Meta:
6367
fields = ("user_id", "type")
6468

6569

70+
class JobTypeFiltersSerializer(serializers.Serializer):
71+
"""
72+
Base serializer for job type-specific filters. This serves as the base class
73+
for all job type filter serializers and uses 'type' as a discriminator field.
74+
"""
75+
76+
type = serializers.ChoiceField(
77+
choices=lazy(job_type_registry.get_types, list)(),
78+
required=True,
79+
help_text="The type of job to filter for. Determines which additional filter fields are available.",
80+
)
81+
82+
6683
class ListJobQuerySerializer(serializers.Serializer):
6784
states = serializers.CharField(required=False)
6885
job_ids = serializers.CharField(required=False)
86+
type = serializers.ChoiceField(
87+
choices=lazy(job_type_registry.get_types, list)(),
88+
required=False,
89+
help_text="The type of job to filter for. Determines which additional filter fields are available.",
90+
)
91+
offset = serializers.IntegerField(required=False, min_value=0)
92+
limit = serializers.IntegerField(
93+
required=False, min_value=1, max_value=100, default=20
94+
)
6995

7096
def validate_states(self, value):
7197
if not value:
@@ -95,3 +121,95 @@ def validate_job_ids(self, value):
95121
f"Job id {job_id} is not a valid integer."
96122
)
97123
return validated_job_ids
124+
125+
def validate(self, attrs):
126+
job_type_name = attrs.get("type")
127+
128+
# Collect type-specific filters in a separate dict
129+
type_filters = {}
130+
131+
if job_type_name:
132+
job_type = job_type_registry.get(job_type_name)
133+
filters_serializer_class = job_type.get_filters_serializer()
134+
135+
if filters_serializer_class:
136+
filters_data = {}
137+
138+
# Add any type-specific fields from initial_data
139+
filters_serializer = filters_serializer_class()
140+
141+
for field_name in filters_serializer.fields.keys():
142+
if field_name in self.initial_data:
143+
filters_data[field_name] = self.initial_data[field_name]
144+
145+
# Validate using the type-specific serializer
146+
filters_serializer = filters_serializer_class(data=filters_data)
147+
if filters_serializer.is_valid():
148+
for field_name, value in filters_serializer.validated_data.items():
149+
# if the field starts with the job_type name to disambiguate
150+
# the query parameter, remove it
151+
field_key = field_name
152+
if field_name.startswith(f"{job_type.type}_"):
153+
field_key = field_name[len(job_type.type) + 1 :]
154+
type_filters[field_key] = value
155+
else:
156+
raise serializers.ValidationError(filters_serializer.errors)
157+
158+
# Add type_filters dict to attrs for easy access in the view
159+
attrs["type_filters"] = type_filters
160+
attrs["job_type_name"] = job_type_name
161+
162+
return attrs
163+
164+
165+
class ListJobQuerySerializerExtension(OpenApiSerializerExtension):
166+
"""
167+
Custom OpenAPI serializer extension that dynamically adds type-specific filter
168+
fields to the ListJobQuerySerializer based on the job registry. This creates a flat
169+
parameter list where type-specific fields appear when the corresponding type is
170+
selected, since it's not possible to use a discriminator in query parameters.
171+
"""
172+
173+
target_class = "baserow.api.jobs.serializers.ListJobQuerySerializer"
174+
175+
def map_serializer(self, auto_schema, direction):
176+
"""
177+
Generate the schema by adding all type-specific fields from job filters
178+
serializers to the base ListJobQuerySerializer properties.
179+
"""
180+
181+
schema = auto_schema._map_serializer(
182+
self.target, direction, bypass_extensions=True
183+
)
184+
185+
properties = schema.get("properties", {})
186+
base_field_names = set(ListJobQuerySerializer().fields.keys())
187+
188+
# Collect all type-specific fields from job registry
189+
for job_type in job_type_registry.get_all():
190+
filters_serializer_class = job_type.get_filters_serializer()
191+
if (
192+
not filters_serializer_class
193+
or filters_serializer_class == JobTypeFiltersSerializer
194+
):
195+
continue
196+
197+
serializer = force_instance(filters_serializer_class)
198+
199+
for field_name, field in serializer.fields.items():
200+
# Skip base fields and the type field
201+
if field_name in base_field_names or field_name == "type":
202+
continue
203+
204+
field_schema = auto_schema._map_serializer_field(field, direction)
205+
206+
help_text = field_schema.get("description", "")
207+
field_schema[
208+
"description"
209+
] = f"**[Only for type='{job_type.type}']** {help_text}"
210+
211+
if field_name not in properties:
212+
properties[field_name] = field_schema
213+
214+
schema["properties"] = properties
215+
return schema

backend/src/baserow/api/jobs/views.py

Lines changed: 25 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
)
1414
from baserow.api.schemas import get_error_schema
1515
from baserow.api.utils import DiscriminatorCustomFieldsMappingSerializer
16+
from baserow.core.db import specific_iterator
1617
from baserow.core.jobs.exceptions import (
1718
JobDoesNotExist,
1819
JobNotCancellable,
@@ -33,30 +34,15 @@ class JobsView(APIView):
3334
permission_classes = (IsAuthenticated,)
3435

3536
@extend_schema(
36-
parameters=[
37-
OpenApiParameter(
38-
name="states",
39-
location=OpenApiParameter.QUERY,
40-
type=OpenApiTypes.STR,
41-
description="A comma separated list of jobs state to look for. "
42-
"The only possible values are: `pending`, `finished`, `failed` and `cancelled`. "
43-
"It's possible to exclude a state by prefixing it with a `!`. ",
44-
),
45-
OpenApiParameter(
46-
name="job_ids",
47-
location=OpenApiParameter.QUERY,
48-
type=OpenApiTypes.STR,
49-
description="A comma separated list of job ids in the desired order."
50-
"The jobs will be returned in the same order as the ids."
51-
"If a job id is not found it will be ignored.",
52-
),
53-
],
37+
parameters=[ListJobQuerySerializer],
5438
tags=["Jobs"],
5539
operation_id="list_job",
5640
description=(
5741
"List all existing jobs. Jobs are task executed asynchronously in the "
58-
"background. You can use the `get_job` endpoint to read the current"
59-
"progress of a the job."
42+
"background. You can use the `get_job` endpoint to read the current "
43+
"progress of the job. The available query parameters depend on the job type "
44+
"selected via the `type` parameter. Each job type may support additional "
45+
"type-specific filter parameters."
6046
),
6147
responses={
6248
200: DiscriminatorCustomFieldsMappingSerializer(
@@ -68,22 +54,33 @@ class JobsView(APIView):
6854
def get(self, request, query_params):
6955
states = query_params.get("states", None)
7056
job_ids = query_params.get("job_ids", None)
57+
offset = query_params.get("offset", 0)
58+
limit = query_params.get("limit", 20)
59+
60+
# Get job type and filters from the validated data
61+
job_type_name = query_params.get("job_type_name", None)
62+
type_filters = query_params.get("type_filters", {})
63+
64+
base_model = None
65+
if job_type_name:
66+
job_type = job_type_registry.get(job_type_name)
67+
base_model = job_type.model_class
7168

7269
jobs = JobHandler.get_jobs_for_user(
73-
request.user, filter_states=states, filter_ids=job_ids
74-
)
70+
request.user,
71+
filter_states=states,
72+
filter_ids=job_ids,
73+
base_model=base_model,
74+
type_filters=type_filters if type_filters else None,
75+
)[offset : offset + limit]
7576

76-
# FIXME: job.specific makes a query for each job to get the specific instance.
77-
# As long as we have max_count=1 for each job type, there's not much we can do,
78-
# but this should be optimized in the future if we allow multiple jobs of the
79-
# same type.
8077
serialized_jobs = [
8178
job_type_registry.get_serializer(
82-
job.specific,
79+
job,
8380
JobSerializer,
8481
context={"request": request},
8582
).data
86-
for job in jobs
83+
for job in specific_iterator(jobs)
8784
]
8885
return Response({"jobs": serialized_jobs})
8986

backend/src/baserow/contrib/database/table/handler.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,8 +279,10 @@ def list_workspace_tables(
279279

280280
table_qs = base_queryset if base_queryset else Table.objects.all()
281281

282-
table_qs = table_qs.filter(database__workspace=workspace).select_related(
283-
"database__workspace", "data_sync"
282+
table_qs = (
283+
table_qs.filter(database__workspace=workspace)
284+
.select_related("database__workspace", "data_sync")
285+
.order_by("database_id", "order", "id")
284286
)
285287

286288
if not include_trashed:

backend/src/baserow/core/jobs/handler.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from datetime import datetime, timedelta, timezone
2-
from typing import List, Optional, Type
2+
from typing import Any, Dict, List, Optional, Type
33

44
from django.conf import settings
55
from django.contrib.auth.models import AbstractUser
@@ -112,6 +112,8 @@ def get_jobs_for_user(
112112
user: AbstractUser,
113113
filter_states: Optional[List[str]],
114114
filter_ids: Optional[List[int]],
115+
base_model: Optional[Type[AnyJob]] = None,
116+
type_filters: Optional[Dict[str, Any]] = None,
115117
) -> QuerySet:
116118
"""
117119
Returns all jobs belonging to the specified user.
@@ -120,9 +122,15 @@ def get_jobs_for_user(
120122
:param filter_states: A list of states that the jobs should have, or not
121123
have if prefixed with a !.
122124
:param filter_ids: A list of specific job ids to return.
125+
:param base_model: An optional Job model.
126+
:param type_filters: Optional type-specific filters (e.g., field_id for
127+
GenerateAIValuesJob).
123128
:return: A QuerySet with the filtered jobs for the user.
124129
"""
125130

131+
if base_model is None:
132+
base_model = Job
133+
126134
def get_job_states_filter(states):
127135
states_q = Q()
128136
for state in states:
@@ -132,14 +140,17 @@ def get_job_states_filter(states):
132140
states_q |= Q(state=state)
133141
return states_q
134142

135-
queryset = Job.objects.filter(user=user).order_by("-updated_on")
143+
queryset = base_model.objects.filter(user=user).order_by("-id")
136144

137145
if filter_states:
138146
queryset = queryset.filter(get_job_states_filter(filter_states))
139147

140148
if filter_ids:
141149
queryset = queryset.filter(id__in=filter_ids)
142150

151+
if type_filters:
152+
queryset = queryset.filter(**type_filters)
153+
143154
return queryset.select_related("content_type")
144155

145156
@classmethod

backend/src/baserow/core/jobs/registries.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any, Dict
1+
from typing import Any, Dict, Type
22

33
from django.contrib.auth.models import AbstractUser
44

@@ -164,6 +164,20 @@ def response_serializer_class(self):
164164
meta_ref_name=f"{self.__class__.__name__}ResponseSerializer",
165165
)
166166

167+
def get_filters_serializer(self) -> Type[serializers.Serializer] | None:
168+
"""
169+
This method enables job types to define custom filters for job listing
170+
operations. Since query parameters cannot utilize Discriminator fields and must
171+
be flattened, all filter field names should be prefixed with the job type name
172+
followed by an underscore to prevent naming conflicts between different job
173+
types.
174+
175+
:return: A serializer class extending JobTypeFiltersSerializer, or None if no
176+
type-specific filters are needed.
177+
"""
178+
179+
return None
180+
167181

168182
class JobTypeRegistry(
169183
CustomFieldsRegistryMixin,

backend/src/baserow/test_utils/fixtures/job.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import Type
2+
13
from rest_framework import serializers
24
from rest_framework.status import HTTP_404_NOT_FOUND
35

@@ -16,6 +18,16 @@ class TestException(Exception):
1618
...
1719

1820

21+
class TmpJobType1FiltersSerializer(serializers.Serializer):
22+
"""Just for testing: expose a filter on progress_percentage"""
23+
24+
tmp_job_type_1_progress_percentage = serializers.IntegerField(
25+
min_value=0,
26+
required=False,
27+
help_text="Filter by the progress percentage.",
28+
)
29+
30+
1931
class TmpJobType1(JobType):
2032
type = "tmp_job_type_1"
2133

@@ -51,6 +63,11 @@ def prepare_values(self, values, user):
5163
def run(self, job, progress):
5264
pass
5365

66+
def get_filters_serializer(self) -> Type[serializers.Serializer] | None:
67+
"""Returns the filters serializer for this job type."""
68+
69+
return TmpJobType1FiltersSerializer
70+
5471

5572
class TmpJobType2(JobType):
5673
type = "tmp_job_type_2"

backend/tests/baserow/api/import_export/test_export_applications_views.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ def test_exporting_empty_workspace(
121121
job_id = response_json["id"]
122122
assert response_json == {
123123
"created_on": run_time,
124+
"updated_on": run_time,
124125
"exported_file_name": None,
125126
"human_readable_error": "",
126127
"id": job_id,
@@ -200,6 +201,7 @@ def test_exporting_workspace_with_single_empty_database(
200201
job_id = response_json["id"]
201202
assert response_json == {
202203
"created_on": run_time,
204+
"updated_on": run_time,
203205
"exported_file_name": None,
204206
"human_readable_error": "",
205207
"id": job_id,

0 commit comments

Comments
 (0)