Skip to content
Open
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
35 changes: 32 additions & 3 deletions products/experiments/backend/experiment_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from zoneinfo import ZoneInfo

from django.db import transaction
from django.db.models import Case, Count, F, Prefetch, Q, QuerySet, Value, When
from django.db.models import Case, CharField, Count, F, IntegerField, Prefetch, Q, QuerySet, Value, When
from django.db.models.functions import Coalesce, Now, NullIf
from django.utils import timezone

Expand Down Expand Up @@ -458,6 +458,16 @@ def validate_experiment_metrics(cls, metrics: list | None) -> None:
"-duration",
"status",
"-status",
"conclusion",
"-conclusion",
}

EXPERIMENT_CONCLUSION_SORT_ORDER = {
"won": 1,
"lost": 2,
"inconclusive": 3,
"stopped_early": 4,
"invalid": 5,
}

ELIGIBLE_FLAGS_ORDER_ALLOWLIST = {
Expand Down Expand Up @@ -2526,13 +2536,32 @@ def filter_experiments_queryset(
# Match the frontend column's `first_name || email` sorter — treat an
# empty `first_name` as missing and fall back to `email`, so users with
# a blank first name aren't bunched at one end of the list.
prefix = "-" if order_value.startswith("-") else ""
order_expression = (
F("created_by_display").desc() if order_value.startswith("-") else F("created_by_display").asc()
)
queryset = queryset.annotate(
created_by_display=Coalesce(
NullIf(F("created_by__first_name"), Value("")),
F("created_by__email"),
Value(""),
output_field=CharField(),
)
).order_by(f"{prefix}created_by_display")
).order_by(order_expression, "-created_at", "id")
elif order_value in ["conclusion", "-conclusion"]:
conclusion_sort_key = Case(
*[
When(conclusion=conclusion, then=Value(sort_order))
for conclusion, sort_order in self.EXPERIMENT_CONCLUSION_SORT_ORDER.items()
],
default=Value(len(self.EXPERIMENT_CONCLUSION_SORT_ORDER) + 1),
output_field=IntegerField(),
)
order_expression = (
F("conclusion_sort_key").desc() if order_value.startswith("-") else F("conclusion_sort_key").asc()
)
queryset = queryset.annotate(conclusion_sort_key=conclusion_sort_key).order_by(
order_expression, "-created_at", "id"
)
else:
queryset = queryset.order_by(order_value)
else:
Expand Down
2 changes: 1 addition & 1 deletion products/experiments/backend/presentation/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ def _slugify_feature_flag_key(name: str, *, team_id: int) -> str:
type=str,
description=(
"Field to order by. Prefix with '-' for descending. Allowlisted fields include name, "
"created_at, updated_at, start_date, end_date, duration, and status."
"created_at, created_by, updated_at, start_date, end_date, duration, status, and conclusion."
),
required=False,
),
Expand Down
83 changes: 83 additions & 0 deletions products/experiments/backend/test/test_presentation_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,89 @@ def test_can_filter_experiments_by_status(self, status_filter: str, expected_sta
self.assertEqual(response.json()["count"], 1)
self.assertEqual(response.json()["results"][0]["status"], expected_status)

def _create_experiment_for_ordering(
self,
*,
name: str,
feature_flag_key: str,
created_by: User | None = None,
conclusion: str | None = None,
) -> Experiment:
flag = FeatureFlag.objects.create(
team=self.team,
created_by=created_by or self.user,
key=feature_flag_key,
filters={
"groups": [{"properties": [], "rollout_percentage": 100}],
"multivariate": {
"variants": [
{"key": "control", "name": "Control", "rollout_percentage": 50},
{"key": "test", "name": "Test", "rollout_percentage": 50},
]
},
},
)
return Experiment.objects.create(
team=self.team,
name=name,
feature_flag=flag,
created_by=created_by,
start_date=datetime(2024, 1, 1, tzinfo=UTC),
end_date=datetime(2024, 1, 2, tzinfo=UTC),
conclusion=conclusion,
)

@parameterized.expand(
[
("conclusion", ["Won", "Lost", "Inconclusive", "Stopped early", "Invalid", "No result"]),
("-conclusion", ["No result", "Invalid", "Stopped early", "Inconclusive", "Lost", "Won"]),
]
)
def test_can_order_experiments_by_conclusion(self, order: str, expected_names: list[str]) -> None:
for name, conclusion in [
("Invalid", "invalid"),
("Won", "won"),
("No result", None),
("Stopped early", "stopped_early"),
("Lost", "lost"),
("Inconclusive", "inconclusive"),
]:
self._create_experiment_for_ordering(
name=name,
feature_flag_key=f"conclusion-order-{name.lower().replace(' ', '-')}",
conclusion=conclusion,
)

response = self.client.get(f"/api/projects/{self.team.id}/experiments/?order={order}")

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual([experiment["name"] for experiment in response.json()["results"]], expected_names)

@parameterized.expand(
[
("created_by", ["No creator", "Alice", "Bob"]),
("-created_by", ["Bob", "Alice", "No creator"]),
]
)
def test_can_order_experiments_by_created_by(self, order: str, expected_names: list[str]) -> None:
alice = User.objects.create_and_join(self.organization, "alice@example.com", None, first_name="alice")
bob = User.objects.create_and_join(self.organization, "bob@example.com", None, first_name="bob")
for name, creator in [
("Bob", bob),
("No creator", None),
("Alice", alice),
]:
self._create_experiment_for_ordering(
name=name,
feature_flag_key=f"created-by-order-{name.lower().replace(' ', '-')}",
created_by=creator,
)

response = self.client.get(f"/api/projects/{self.team.id}/experiments/?order={order}")

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual([experiment["name"] for experiment in response.json()["results"]], expected_names)

def _create_experiment_with_metric_event(self, name: str, flag_key: str, event: str) -> Experiment:
flag = FeatureFlag.objects.create(
team=self.team,
Expand Down
2 changes: 1 addition & 1 deletion products/experiments/frontend/generated/api.schemas.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion services/mcp/src/api/generated.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion services/mcp/src/generated/experiments/api.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.