Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
45bf279
UN-3770 [FIX] Make list pagination consistent across shared resource …
chandrasekharan-zipstack Jul 24, 2026
c2ec0dc
UN-3770 [FIX] Address review: pk tie-breaker on client ordering, shar…
chandrasekharan-zipstack Jul 24, 2026
45156b8
UN-3770 [MISC] Scope to backend + selectors; drop listing-page conver…
chandrasekharan-zipstack Jul 27, 2026
fd40c16
UN-3769 [FEAT] Sortable resource lists with co-owner ownership (#2200)
kirtimanmishrazipstack Jul 28, 2026
f4adbeb
Merge branch 'main' into feat/list-pagination-consistency
kirtimanmishrazipstack Jul 29, 2026
d28123a
UN-3770 [FIX] Address review: deterministic owner, keyboard row actio…
chandrasekharan-zipstack Jul 29, 2026
8fc7916
UN-3770 [FIX] Use aria-disabled on row actions so the deprecated tool…
chandrasekharan-zipstack Jul 29, 2026
3550f42
UN-3770 [FIX] Polish resource list table: sort affordance + layout
chandrasekharan-zipstack Jul 29, 2026
c1cc997
UN-3770 [FEAT] Search resource lists by owner too, not just name
chandrasekharan-zipstack Jul 29, 2026
368a143
UN-3770 [FEAT] Update search placeholder to "Search by name or owner"
chandrasekharan-zipstack Jul 29, 2026
f992b3f
UN-3770 [FEAT] List all co-owners in the Owned By tooltip
chandrasekharan-zipstack Jul 29, 2026
9fed831
UN-3770 [REFACTOR] Collapse owner_email into owner_emails
chandrasekharan-zipstack Jul 29, 2026
dfc9037
UN-3770 [REVERT] Drop the table column-width/layout tweaks for now
chandrasekharan-zipstack Jul 29, 2026
3853476
UN-3770 [FEAT] ResourceTable: extraColumns + onRowClick (for lookups)…
chandrasekharan-zipstack Jul 30, 2026
8d05a7b
chore: re-trigger pre-commit.ci (transient mergeable-check error)
chandrasekharan-zipstack Jul 30, 2026
7c66465
UN-3770 [FIX] Address review: owner fallback+mask, scoped tiebreaker,…
chandrasekharan-zipstack Jul 30, 2026
8d0f4bc
UN-3770 [FIX] Address Greptile: stable log pagination + drop stale li…
chandrasekharan-zipstack Jul 30, 2026
2e47770
Merge branch 'main' into feat/list-pagination-consistency
chandrasekharan-zipstack Jul 30, 2026
d6562f4
UN-3770 [FIX] Route remaining paginated viewsets through deterministi…
chandrasekharan-zipstack Jul 30, 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.30 on 2026-07-30 09:07

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("adapter_processor_v2", "0005_absorb_shared_users"),
]

operations = [
migrations.AddIndex(
model_name="adapterinstance",
index=models.Index(
fields=["organization", "-modified_at"], name="adapter_org_modified_idx"
),
),
]
21 changes: 12 additions & 9 deletions backend/adapter_processor_v2/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,11 @@ def for_user(self, user: User) -> QuerySet[Any]:
group_shared_ids = resources_visible_via_groups(self.model, user_group_ids)
member_ids = resources_visible_via_memberships(self.model, user)

return (
self.get_queryset()
.filter(
models.Q(pk__in=member_ids)
| models.Q(shared_to_org=True)
| models.Q(is_friction_less=True)
| models.Q(pk__in=group_shared_ids)
)
.distinct("id")
return self.get_queryset().filter(
models.Q(pk__in=member_ids)
| models.Q(shared_to_org=True)
| models.Q(is_friction_less=True)
| models.Q(pk__in=group_shared_ids)
)


Expand Down Expand Up @@ -172,6 +168,13 @@ class Meta:
name="unique_organization_adapter",
),
]
# Backs the default org-scoped `-modified_at, pk` list ordering.
indexes = [
models.Index(
fields=["organization", "-modified_at"],
name="adapter_org_modified_idx",
),
]

def create_adapter(self) -> None:
encryption_secret: str = settings.ENCRYPTION_KEY
Expand Down
4 changes: 4 additions & 0 deletions backend/adapter_processor_v2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,10 +207,14 @@ def to_representation(self, instance: AdapterInstance) -> dict[str, str]:
if model:
rep["model"] = model

# Frictionless (Unstract-provisioned) adapters mask the owner org-wide;
# mask owner_emails too, else the Owned By column leaks the real owner.
if instance.is_friction_less:
rep["created_by_email"] = "Unstract"
rep["owner_emails"] = ["Unstract"]
else:
rep["created_by_email"] = instance.created_by.email
rep["owner_emails"] = instance.owner_emails()

request = self.context.get("request")
rep["is_owner"] = instance.is_owner(request.user) if request else False
Expand Down
18 changes: 13 additions & 5 deletions backend/adapter_processor_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@ class AdapterInstanceViewSet(
):
serializer_class = AdapterInstanceSerializer
pagination_class = OptionalPagination
# `pk` tiebreaker keeps paging deterministic when modified_at collides.
ordering = ["-modified_at", "pk"]
ordering_fields = ["adapter_name", "created_at", "modified_at"]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
notification_resource_name_field = "adapter_name"
Comment thread
greptile-apps[bot] marked this conversation as resolved.

def get_notification_resource_type(self, resource: Any) -> str | None:
Expand Down Expand Up @@ -192,12 +195,17 @@ def get_queryset(self) -> QuerySet | None:

search = self.request.query_params.get("search")
if search:
queryset = queryset.filter(adapter_name__icontains=search)
from django.db.models import Q
from tenant_account_v2.sharing_helpers import (
resources_matching_owner_search,
)

queryset = queryset.filter(
Q(adapter_name__icontains=search)
| Q(pk__in=resources_matching_owner_search(queryset.model, search))
)

# Order by the DISTINCT ON field so pagination is deterministic and the
# admin/service branch (no distinct) is ordered too. Not modified_at:
# that would conflict with the DISTINCT ON in for_user().
return queryset.order_by("id")
return queryset

def get_serializer_class(
self,
Expand Down
2 changes: 1 addition & 1 deletion backend/backend/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -627,7 +627,7 @@ def filter(self, record):
"DEFAULT_FILTER_BACKENDS": [
"utils.filters.organization_filter.OrganizationFilterBackend",
"django_filters.rest_framework.DjangoFilterBackend",
"rest_framework.filters.OrderingFilter",
"utils.filters.ordering_filter.DeterministicOrderingFilter",
Comment thread
greptile-apps[bot] marked this conversation as resolved.
],
# For API versioning
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.30 on 2026-07-30 09:07

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("connector_v2", "0007_absorb_shared_users"),
]

operations = [
migrations.AddIndex(
model_name="connectorinstance",
index=models.Index(
fields=["organization", "-modified_at"], name="connector_org_modified_idx"
),
),
]
19 changes: 11 additions & 8 deletions backend/connector_v2/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,10 @@ def for_user(self, user: User) -> models.QuerySet:
group_shared_ids = resources_visible_via_groups(self.model, user_group_ids)
member_ids = resources_visible_via_memberships(self.model, user)

return (
self.get_queryset()
.filter(
models.Q(pk__in=member_ids)
| models.Q(shared_to_org=True)
| models.Q(pk__in=group_shared_ids)
)
.distinct("id")
return self.get_queryset().filter(
models.Q(pk__in=member_ids)
| models.Q(shared_to_org=True)
| models.Q(pk__in=group_shared_ids)
)


Expand Down Expand Up @@ -173,3 +169,10 @@ class Meta:
name="unique_organization_connector",
),
]
# Backs the default org-scoped `-modified_at, pk` list ordering.
indexes = [
models.Index(
fields=["organization", "-modified_at"],
name="connector_org_modified_idx",
),
]
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_emails"] = instance.owner_emails()

return rep

Expand Down
24 changes: 16 additions & 8 deletions backend/connector_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ class ConnectorInstanceViewSet(
versioning_class = URLPathVersioning
serializer_class = ConnectorInstanceSerializer
pagination_class = OptionalPagination
# `pk` tiebreaker keeps paging deterministic when modified_at collides.
ordering = ["-modified_at", "pk"]
ordering_fields = ["connector_name", "created_at", "modified_at"]
notification_resource_name_field = "connector_name"

def get_notification_resource_type(self, resource: Any) -> str | None:
Expand Down Expand Up @@ -105,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 @@ -127,10 +126,19 @@ def get_queryset(self) -> QuerySet | None:
)
queryset = queryset.none()

# Order by the DISTINCT ON field so pagination is deterministic and the
# admin/service branch (no distinct) is ordered too. Not modified_at:
# that would conflict with the DISTINCT ON in for_user().
return queryset.order_by("id")
search = self.request.query_params.get("search")
if search:
from django.db.models import Q
from tenant_account_v2.sharing_helpers import (
resources_matching_owner_search,
)

queryset = queryset.filter(
Q(connector_name__icontains=search)
| Q(pk__in=resources_matching_owner_search(queryset.model, search))
)

return queryset

def _get_connector_metadata(self, connector_id: str) -> dict[str, str] | None:
"""Gets connector metadata for the ConnectorInstance.
Expand Down
4 changes: 2 additions & 2 deletions backend/dashboard_metrics/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.filters import OrderingFilter
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.throttling import UserRateThrottle
from utils.filters.ordering_filter import DeterministicOrderingFilter
from utils.user_context import UserContext

from .cache import (
Expand Down Expand Up @@ -156,7 +156,7 @@ class DashboardMetricsViewSet(viewsets.ReadOnlyModelViewSet):
permission_classes = [IsAuthenticated, IsOrganizationMember]
throttle_classes = [MetricsRateThrottle]
serializer_class = EventMetricsHourlySerializer
filter_backends = [DjangoFilterBackend, OrderingFilter]
filter_backends = [DjangoFilterBackend, DeterministicOrderingFilter]
ordering_fields = ["timestamp", "metric_name", "metric_value"]
ordering = ["-timestamp"]

Expand Down
26 changes: 26 additions & 0 deletions backend/permissions/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,32 @@ 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
# pk breaks created_at ties so the label is stable across requests.
return min(owners, key=lambda m: (m.created_at, m.pk)).user.email

def owner_emails(self) -> list[str]:
# Every live OWNER email, earliest-first (``owner_email`` is just the
# head). Backs the Owned By tooltip so all co-owners are named, not only
# the primary + a ``+N`` count. Reads prefetched ``memberships``.
owners = [
m
for m in self.memberships.all() # type: ignore[attr-defined]
if m.role == ResourceRole.OWNER and not m.user.is_service_account
]
owners.sort(key=lambda m: (m.created_at, m.pk))
return [m.user.email for m in owners]

def is_owner(self, user: Any) -> bool:
if user is None:
return False
Expand Down
4 changes: 2 additions & 2 deletions backend/pipeline_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@
from plugins import get_plugin
from rest_framework import serializers, status, viewsets
from rest_framework.decorators import action
from rest_framework.filters import OrderingFilter
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.versioning import URLPathVersioning
from scheduler.helper import SchedulerHelper
from utils.filters.ordering_filter import DeterministicOrderingFilter
from utils.pagination import CustomPagination

from pipeline_v2.constants import (
Expand Down Expand Up @@ -51,7 +51,7 @@ class PipelineViewSet(
versioning_class = URLPathVersioning
queryset = Pipeline.objects.all()
pagination_class = CustomPagination
filter_backends = [OrderingFilter]
filter_backends = [DeterministicOrderingFilter]
ordering_fields = ["created_at", "last_run_time", "pipeline_name", "run_count"]
# Note: Default ordering with nulls_last is applied in get_queryset()
# DRF's ordering attribute doesn't support nulls_last natively
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.30 on 2026-07-30 09:07

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("prompt_studio_core_v2", "0009_absorb_shared_users"),
]

operations = [
migrations.AddIndex(
model_name="customtool",
index=models.Index(
fields=["organization", "-modified_at"], name="custtool_org_modified_idx"
),
),
]
19 changes: 11 additions & 8 deletions backend/prompt_studio/prompt_studio_core_v2/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,10 @@ def for_user(self, user: User) -> QuerySet[Any]:
group_shared_ids = resources_visible_via_groups(self.model, user_group_ids)
member_ids = resources_visible_via_memberships(self.model, user)

return (
self.get_queryset()
.filter(
models.Q(pk__in=member_ids)
| models.Q(shared_to_org=True)
| models.Q(pk__in=group_shared_ids)
)
.distinct("tool_id")
return self.get_queryset().filter(
models.Q(pk__in=member_ids)
| models.Q(shared_to_org=True)
| models.Q(pk__in=group_shared_ids)
)


Expand Down Expand Up @@ -226,3 +222,10 @@ class Meta:
name="unique_tool_name",
),
]
# Backs the default org-scoped `-modified_at, pk` list ordering.
indexes = [
models.Index(
fields=["organization", "-modified_at"],
name="custtool_org_modified_idx",
),
]
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_emails = serializers.SerializerMethodField()

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

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_emails(self, instance) -> list[str]:
return instance.owner_emails()

def get_prompt_count(self, instance):
if hasattr(instance, "_prompt_count"):
return instance._prompt_count or 0
Expand Down
Loading
Loading