Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
45cafd5
UN-3769 [FEAT] Sortable resource list table with server-side sort, se…
kirtimanmishrazipstack Jul 23, 2026
821fbee
UN-3769 [MISC] Merge main into UN-3769; keep ListView removed
kirtimanmishrazipstack Jul 23, 2026
049a129
UN-3769 [FIX] Dedupe list fetch into shared helpers; fix stale-respon…
kirtimanmishrazipstack Jul 23, 2026
4bfae22
UN-3769 [FIX] Collapse duplicated list-page preamble to clear duplica…
kirtimanmishrazipstack Jul 23, 2026
b76e2e2
UN-3769 [FIX] Gate list-fetch catch/finally on the request sequence
kirtimanmishrazipstack Jul 23, 2026
c435b06
UN-3769 [FIX] Show a retryable error on list-fetch failure; use codeP…
kirtimanmishrazipstack Jul 23, 2026
f6c0160
UN-3769 [FIX] Track "Me" in the Owned By cell by displayed owner, not…
kirtimanmishrazipstack Jul 23, 2026
db24151
UN-3769 [FIX] Gate delete-failure loading clear on the request sequence
kirtimanmishrazipstack Jul 23, 2026
52d9d11
UN-3769 [FIX] Keep adapter delete out of the shared list-loading state
kirtimanmishrazipstack Jul 23, 2026
5947bcc
UN-3769 [FIX] Refresh the current list view, not the params captured …
kirtimanmishrazipstack Jul 23, 2026
83a460b
UN-3769 [FIX] Fix list fetch state handling and dead pagination
kirtimanmishrazipstack Jul 24, 2026
db133f5
UN-3769 [FIX] Address self-review on resource list views
kirtimanmishrazipstack Jul 24, 2026
7c35db4
Merge branch 'main' into UN-3769-Show-co-owner-ownership-in-unstract-…
kirtimanmishrazipstack Jul 24, 2026
ec01f9d
Merge branch 'main' into UN-3769-Show-co-owner-ownership-in-unstract-…
kirtimanmishrazipstack Jul 27, 2026
ac31a91
UN-3769 [FIX] Match resource-table owner avatars to Figma pastel palette
kirtimanmishrazipstack Jul 27, 2026
d02561b
UN-3769 [FIX] Lighten resource-table owner-avatar initials per design
kirtimanmishrazipstack Jul 27, 2026
dac50f9
UN-3769 [MISC] Merge feat/list-pagination-consistency (#2208); move r…
kirtimanmishrazipstack Jul 28, 2026
7a93408
UN-3769 [FEAT] Resource list: add Modified column + PS prompt count, …
kirtimanmishrazipstack Jul 28, 2026
a6d1eb9
UN-3769 [FEAT] Resource list: relative Modified time, canonical date …
kirtimanmishrazipstack Jul 28, 2026
8401a95
UN-3769 [FIX] Resource list review fixes: name-only search, clear-sor…
kirtimanmishrazipstack Jul 28, 2026
98062a1
UN-3769 [FIX] Tests: pin name-only search, dropped owner-ordering fal…
kirtimanmishrazipstack Jul 28, 2026
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
1 change: 1 addition & 0 deletions backend/adapter_processor_v2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ def to_representation(self, instance: AdapterInstance) -> dict[str, str]:
request = self.context.get("request")
rep["is_owner"] = instance.is_owner(request.user) if request else False
rep["co_owners_count"] = instance.co_owners_count()
rep["owner_email"] = instance.owner_email()

return rep

Expand Down
1 change: 1 addition & 0 deletions backend/adapter_processor_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ def get_queryset(self) -> QuerySet | None:
):
queryset = queryset.filter(**filter_args)

# Name search.
search = self.request.query_params.get("search")
if search:
queryset = queryset.filter(adapter_name__icontains=search)
Expand Down
1 change: 1 addition & 0 deletions backend/connector_v2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ def to_representation(self, instance: ConnectorInstance) -> dict[str, str]:
request = self.context.get("request")
rep["is_owner"] = instance.is_owner(request.user) if request else False
rep["co_owners_count"] = instance.co_owners_count()
rep["owner_email"] = instance.owner_email()

return rep

Expand Down
9 changes: 5 additions & 4 deletions backend/connector_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,6 @@ def get_queryset(self) -> QuerySet | None:
if filter_args:
queryset = queryset.filter(**filter_args)

search = self.request.query_params.get("search")
if search:
queryset = queryset.filter(connector_name__icontains=search)

# Filter by connector_mode
connector_mode_param = self.request.query_params.get("connector_mode")
if connector_mode_param:
Expand All @@ -130,6 +126,11 @@ def get_queryset(self) -> QuerySet | None:
)
queryset = queryset.none()

# Name search.
search = self.request.query_params.get("search")
if search:
queryset = queryset.filter(connector_name__icontains=search)

return queryset

def _get_connector_metadata(self, connector_id: str) -> dict[str, str] | None:
Expand Down
13 changes: 13 additions & 0 deletions backend/permissions/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,19 @@ def co_owners_count(self) -> int:
for m in self.memberships.all() # type: ignore[attr-defined]
)

def owner_email(self) -> str | None:
# "Owned By" email: earliest live OWNER. ``created_by`` is audit-only
# (UN-2202) and may differ from the owner. Reads prefetched
# ``memberships`` to stay query-free.
owners = [
m
for m in self.memberships.all() # type: ignore[attr-defined]
if m.role == ResourceRole.OWNER and not m.user.is_service_account
]
if not owners:
return None
return min(owners, key=lambda m: m.created_at).user.email

def is_owner(self, user: Any) -> bool:
if user is None:
return False
Expand Down
5 changes: 5 additions & 0 deletions backend/prompt_studio/prompt_studio_core_v2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class CustomToolListSerializer(serializers.ModelSerializer):
prompt_count = serializers.SerializerMethodField()
is_owner = serializers.SerializerMethodField()
co_owners_count = serializers.SerializerMethodField()
owner_email = serializers.SerializerMethodField()

class Meta:
model = CustomTool
Expand All @@ -65,6 +66,7 @@ class Meta:
"prompt_count",
"is_owner",
"co_owners_count",
"owner_email",
]

def get_created_by_email(self, instance):
Expand All @@ -77,6 +79,9 @@ def get_is_owner(self, instance) -> bool:
def get_co_owners_count(self, instance) -> int:
return instance.co_owners_count()

def get_owner_email(self, instance) -> str | None:
return instance.owner_email()

def get_prompt_count(self, instance):
if hasattr(instance, "_prompt_count"):
return instance._prompt_count or 0
Expand Down
7 changes: 2 additions & 5 deletions backend/prompt_studio/prompt_studio_core_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,11 +173,8 @@ def get_queryset(self) -> QuerySet | None:
.annotate(cnt=Count("prompt_id"))
.values("cnt")
)
# modified_at needs no annotation: prompt writes bump the parent
# row at the source (ToolStudioPrompt.save/delete, sync_prompts),
# keeping the plain field orderable. Only prompt writes bump —
# profile/document edits and queryset-level prompt writes do not;
# any new write path must bump CustomTool itself
# modified_at stays a plain orderable field: prompt writes bump the
# parent row, so no annotation is needed.
qs = qs.select_related("created_by").annotate(
_prompt_count=Subquery(prompt_count_sq),
)
Expand Down
68 changes: 68 additions & 0 deletions backend/utils/tests/test_list_pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
_build_connector,
_build_custom_tool,
_build_workflow,
make_user,
)
from prompt_studio.prompt_studio_core_v2.views import PromptStudioCoreView
from rest_framework import status
Expand Down Expand Up @@ -234,3 +235,70 @@ def test_search_narrows_rows_and_count(self) -> None:
names = self._names(endpoint, response.data["results"])
assert response.data["count"] == 3
assert all("alpha" in name for name in names)

def test_search_matches_name_not_owner_email(self) -> None:
"""``?search=`` matches the resource name only, not the owner's email.

UN-3769 narrowed search from owner-inclusive (``created_by__email``) to
name-only so the search box and the Owned By column agree; the owner's
email substring must no longer return their rows.
"""
for endpoint in LIST_ENDPOINTS:
with self.subTest(kind=endpoint.kind):
self._create(endpoint, f"{endpoint.kind}-searchable", owner=self.owner)

by_email = self._list(
endpoint, self.owner, page=1, page_size=10, search="owner"
)
by_name = self._list(
endpoint, self.owner, page=1, page_size=10, search="searchable"
)

assert by_email.data["count"] == 0
assert by_name.data["count"] == 1

def test_dropped_owner_ordering_field_is_ignored(self) -> None:
"""``?ordering=created_by__email`` is a dropped field, so it's ignored.

UN-3769 removed ``created_by__email`` from ``ordering_fields``. DRF drops
an unknown ordering key and falls back to the view default
(``-modified_at, pk``) rather than 400ing, so the rows stay newest-first.
Every row shares one creator, so had the field survived the response
would be pk-ordered, not the newest-first sequence asserted here.
"""
for endpoint in LIST_ENDPOINTS:
with self.subTest(kind=endpoint.kind):
for i in range(5):
obj = self._create(endpoint, f"{endpoint.kind}-ord-{i}")
self._stamp(obj, BASE_TIME + timedelta(minutes=i))
expected = [f"{endpoint.kind}-ord-{i}" for i in reversed(range(5))]

response = self._list(
endpoint,
self.owner,
page=1,
page_size=10,
ordering="created_by__email",
)

assert self._names(endpoint, response.data["results"]) == expected

def test_owner_email_is_earliest_live_owner(self) -> None:
"""``owner_email()`` (the Owned By label) names the earliest live OWNER,
skips service accounts, and is ``None`` with no owner. Shared-mixin
behaviour, so one endpoint pins it for all four.
"""
svc = make_user("svc@example.com", is_service_account=True)
wf = _build_workflow(self.org, self.owner)
# Service account owns earliest (must be skipped); coowner then owns
# before owner, so coowner is the earliest live owner.
for user, minute in ((svc, 0), (self.coowner, 1), (self.owner, 2)):
membership = wf.memberships.create(user=user, role=ResourceRole.OWNER)
type(membership).objects.filter(pk=membership.pk).update(
created_at=BASE_TIME + timedelta(minutes=minute)
)

assert type(wf).objects.get(pk=wf.pk).owner_email() == self.coowner.email

wf.memberships.all().delete()
assert type(wf).objects.get(pk=wf.pk).owner_email() is None
1 change: 1 addition & 0 deletions backend/workflow_manager/workflow_v2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ def to_representation(self, instance: Workflow) -> dict[str, str]:
request = self.context.get("request")
representation["is_owner"] = instance.is_owner(request.user) if request else False
representation["co_owners_count"] = instance.co_owners_count()
representation["owner_email"] = instance.owner_email()
return representation

def create(self, validated_data: dict[str, Any]) -> Any:
Expand Down
13 changes: 6 additions & 7 deletions backend/workflow_manager/workflow_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,17 +107,16 @@ def get_queryset(self) -> QuerySet:
WorkflowKey.WF_IS_ACTIVE,
WorkflowKey.WF_NAME,
)
# Use for_user method to include shared workflows
queryset = (
Workflow.objects.for_user(self.request.user).filter(**filter_args)
if filter_args
else Workflow.objects.for_user(self.request.user)
)
# Avoid per-row queries for owner/co-owner + creator fields in list views
# Use for_user to include shared workflows; prefetch owner/co-owner
# joins to avoid per-row queries in the Owned By column.
queryset = Workflow.objects.for_user(self.request.user)
if filter_args:
queryset = queryset.filter(**filter_args)
queryset = queryset.select_related("created_by").prefetch_related(
"memberships__user"
)

# Name search.
search = self.request.query_params.get("search")
if search:
queryset = queryset.filter(workflow_name__icontains=search)
Expand Down
Loading