From 2be57e246761b452c1db3d9232833fd14476470a Mon Sep 17 00:00:00 2001 From: Sriram Veeraghanta Date: Sun, 30 Aug 2026 19:06:18 +0530 Subject: [PATCH] fix(api): expand null relations to null instead of an empty object `?expand=updated_by` was reported in #4639 as returning a bare UUID because `updated_by` was missing from the expansion mapper. #7667 added the key, but that alone did not fix the reported request: `BaseModel.save` leaves `updated_by` NULL until a record is first updated, and expanding a null relation called `UserLiteSerializer(None)`, which DRF resolves via `get_initial()` to `{}`. The app layer never got the key at all, and its null case produced a ghost user with blank names. Resolve the relation off the instance instead of guessing arity from the already-serialized value, and keep a null relation null so the expanded and unexpanded responses agree. Add `updated_by` to the app mapper, and hoist its two duplicated copies into one `get_expansion_mapper()` -- the duplication is why the original fix reached only one of them. `issue_attachment` is deliberately left out of the unified mapper: it is the reverse manager of the legacy `IssueAttachment` model, which `IssueAttachmentLiteSerializer` (`model = FileAsset`) cannot serialize. Also document the supported `expand` values, which were previously undiscoverable, and add the first test coverage for `expand`. Claude-Session: https://claude.ai/code/session_0142ihfx57JhqVnvs5w73aX7 --- apps/api/plane/api/serializers/base.py | 33 ++- apps/api/plane/app/serializers/base.py | 214 +++++++++--------- apps/api/plane/tests/contract/api/conftest.py | 32 +++ .../plane/tests/contract/api/test_projects.py | 85 +++++++ .../tests/unit/serializers/test_expand.py | 180 +++++++++++++++ apps/api/plane/utils/openapi/parameters.py | 19 +- 6 files changed, 444 insertions(+), 119 deletions(-) create mode 100644 apps/api/plane/tests/contract/api/conftest.py create mode 100644 apps/api/plane/tests/unit/serializers/test_expand.py diff --git a/apps/api/plane/api/serializers/base.py b/apps/api/plane/api/serializers/base.py index 2e39a01fd5b..2b53f0796d3 100644 --- a/apps/api/plane/api/serializers/base.py +++ b/apps/api/plane/api/serializers/base.py @@ -2,10 +2,17 @@ # SPDX-License-Identifier: AGPL-3.0-only # See the LICENSE file for details. +# Django imports +from django.db import models + # Third party imports from rest_framework import serializers +# Distinguishes "the instance has no such attribute" from "the attribute is None". +_MISSING = object() + + class BaseSerializer(serializers.ModelSerializer): """ Base serializer providing common functionality for all model serializers. @@ -106,11 +113,27 @@ def to_representation(self, instance): } # Check if field in expansion then expand the field if expand in expansion: - if isinstance(response.get(expand), list): - exp_serializer = expansion[expand](getattr(instance, expand), many=True) - else: - exp_serializer = expansion[expand](getattr(instance, expand)) - response[expand] = exp_serializer.data + # Resolve against the instance rather than guessing arity from the + # already-serialized value: a to-many relation the serializer does + # not render is not a list in `response`. `_MISSING` separates "no + # such relation" from "the relation is null" -- a missing reverse + # relation raises RelatedObjectDoesNotExist, an AttributeError. + related = getattr(instance, expand, _MISSING) + if isinstance(related, models.Manager): + response[expand] = expansion[expand](related, many=True).data + elif isinstance(related, models.Model): + response[expand] = expansion[expand](related).data + elif related is None: + # A null relation stays null, matching the unexpanded + # response. Serializing None emits an object built from the + # nested serializer's defaults instead, which is what made + # `expand` unusable for `updated_by`: it is null until the + # first update. + response[expand] = None + # Anything else means `expand` names something that is not a + # relation on this instance -- a queryset annotation or a + # SerializerMethodField of the same name. Leave the value the + # serializer already produced rather than clobbering it. else: # You might need to handle this case differently response[expand] = getattr(instance, f"{expand}_id", None) diff --git a/apps/api/plane/app/serializers/base.py b/apps/api/plane/app/serializers/base.py index 6457eec50ac..1584147f458 100644 --- a/apps/api/plane/app/serializers/base.py +++ b/apps/api/plane/app/serializers/base.py @@ -2,9 +2,88 @@ # SPDX-License-Identifier: AGPL-3.0-only # See the LICENSE file for details. +from django.db import models from rest_framework import serializers +# Distinguishes "the instance has no such attribute" from "the attribute is None". +_MISSING = object() + + +# Relations in the expansion mapper that are to-many. +MANY_EXPANSION_FIELDS = frozenset( + { + "members", + "assignees", + "labels", + "issue_cycle", + "issue_relation", + "issue_intake", + "issue_reactions", + "issue_link", + "sub_issues", + "issue_related", + } +) + + +def get_expansion_mapper(): + """Return the ``expand`` key -> serializer mapping. + + Shared by ``_filter_fields`` and ``to_representation`` so the two cannot drift + apart -- keeping two copies is how ``updated_by`` came to be added to the + ``/api/v1/`` mapper and to neither of these (makeplane/plane#4639). + + ``issue_attachment`` is deliberately absent: ``Issue.issue_attachment`` is the + reverse manager of the legacy ``IssueAttachment`` model, which + ``IssueAttachmentLiteSerializer`` (``model = FileAsset``) cannot serialize. + Attachments are served by the ``issue_attachments`` block in + ``to_representation`` below. + + Imports stay inside the function because the serializers import this module. + """ + from . import ( + WorkspaceLiteSerializer, + ProjectLiteSerializer, + UserLiteSerializer, + StateLiteSerializer, + IssueSerializer, + LabelSerializer, + CycleIssueSerializer, + IssueLiteSerializer, + IssueRelationSerializer, + IntakeIssueLiteSerializer, + IssueReactionLiteSerializer, + IssueLinkLiteSerializer, + RelatedIssueSerializer, + ) + + return { + "user": UserLiteSerializer, + "workspace": WorkspaceLiteSerializer, + "project": ProjectLiteSerializer, + "default_assignee": UserLiteSerializer, + "project_lead": UserLiteSerializer, + "state": StateLiteSerializer, + "created_by": UserLiteSerializer, + "updated_by": UserLiteSerializer, + "issue": IssueSerializer, + "actor": UserLiteSerializer, + "owned_by": UserLiteSerializer, + "members": UserLiteSerializer, + "assignees": UserLiteSerializer, + "labels": LabelSerializer, + "issue_cycle": CycleIssueSerializer, + "parent": IssueLiteSerializer, + "issue_relation": IssueRelationSerializer, + "issue_intake": IntakeIssueLiteSerializer, + "issue_related": RelatedIssueSerializer, + "issue_reactions": IssueReactionLiteSerializer, + "issue_link": IssueLinkLiteSerializer, + "sub_issues": IssueLiteSerializer, + } + + class BaseSerializer(serializers.ModelSerializer): id = serializers.PrimaryKeyRelatedField(read_only=True) @@ -52,70 +131,12 @@ def _filter_fields(self, fields): elif isinstance(item, dict): allowed.append(list(item.keys())[0]) - for field in allowed: - if field not in self.fields: - from . import ( - WorkspaceLiteSerializer, - ProjectLiteSerializer, - UserLiteSerializer, - StateLiteSerializer, - IssueSerializer, - LabelSerializer, - CycleIssueSerializer, - IssueLiteSerializer, - IssueRelationSerializer, - IntakeIssueLiteSerializer, - IssueReactionLiteSerializer, - IssueLinkLiteSerializer, - RelatedIssueSerializer, - ) - - # Expansion mapper - expansion = { - "user": UserLiteSerializer, - "workspace": WorkspaceLiteSerializer, - "project": ProjectLiteSerializer, - "default_assignee": UserLiteSerializer, - "project_lead": UserLiteSerializer, - "state": StateLiteSerializer, - "created_by": UserLiteSerializer, - "issue": IssueSerializer, - "actor": UserLiteSerializer, - "owned_by": UserLiteSerializer, - "members": UserLiteSerializer, - "assignees": UserLiteSerializer, - "labels": LabelSerializer, - "issue_cycle": CycleIssueSerializer, - "parent": IssueLiteSerializer, - "issue_relation": IssueRelationSerializer, - "issue_intake": IntakeIssueLiteSerializer, - "issue_related": RelatedIssueSerializer, - "issue_reactions": IssueReactionLiteSerializer, - "issue_link": IssueLinkLiteSerializer, - "sub_issues": IssueLiteSerializer, - } - - if field not in self.fields and field in expansion: - self.fields[field] = expansion[field]( - many=( - True - if field - in [ - "members", - "assignees", - "labels", - "issue_cycle", - "issue_relation", - "issue_intake", - "issue_reactions", - "issue_attachment", - "issue_link", - "sub_issues", - "issue_related", - ] - else False - ) - ) + missing = [field for field in allowed if field not in self.fields] + if missing: + expansion = get_expansion_mapper() + for field in missing: + if field in expansion: + self.fields[field] = expansion[field](many=field in MANY_EXPANSION_FIELDS) return self.fields @@ -124,58 +145,26 @@ def to_representation(self, instance): # Ensure 'expand' is iterable before processing if self.expand: + expansion = get_expansion_mapper() for expand in self.expand: if expand in self.fields: - # Import all the expandable serializers - from . import ( - WorkspaceLiteSerializer, - ProjectLiteSerializer, - UserLiteSerializer, - StateLiteSerializer, - IssueSerializer, - LabelSerializer, - CycleIssueSerializer, - IssueRelationSerializer, - IntakeIssueLiteSerializer, - IssueLiteSerializer, - IssueReactionLiteSerializer, - IssueAttachmentLiteSerializer, - IssueLinkLiteSerializer, - RelatedIssueSerializer, - ) - - # Expansion mapper - expansion = { - "user": UserLiteSerializer, - "workspace": WorkspaceLiteSerializer, - "project": ProjectLiteSerializer, - "default_assignee": UserLiteSerializer, - "project_lead": UserLiteSerializer, - "state": StateLiteSerializer, - "created_by": UserLiteSerializer, - "issue": IssueSerializer, - "actor": UserLiteSerializer, - "owned_by": UserLiteSerializer, - "members": UserLiteSerializer, - "assignees": UserLiteSerializer, - "labels": LabelSerializer, - "issue_cycle": CycleIssueSerializer, - "parent": IssueLiteSerializer, - "issue_relation": IssueRelationSerializer, - "issue_intake": IntakeIssueLiteSerializer, - "issue_related": RelatedIssueSerializer, - "issue_reactions": IssueReactionLiteSerializer, - "issue_attachment": IssueAttachmentLiteSerializer, - "issue_link": IssueLinkLiteSerializer, - "sub_issues": IssueLiteSerializer, - } # Check if field in expansion then expand the field if expand in expansion: - if isinstance(response.get(expand), list): - exp_serializer = expansion[expand](getattr(instance, expand), many=True) - else: - exp_serializer = expansion[expand](getattr(instance, expand)) - response[expand] = exp_serializer.data + # Resolve against the instance rather than guessing arity from the + # already-serialized value. `_MISSING` separates "no such relation" + # from "the relation is null": `members` and `sub_issues` are + # SerializerMethodField/annotation names on some serializers here, + # and those must keep the value the serializer already produced. + related = getattr(instance, expand, _MISSING) + if isinstance(related, models.Manager): + response[expand] = expansion[expand](related, many=True).data + elif isinstance(related, models.Model): + response[expand] = expansion[expand](related).data + elif related is None: + # A null relation stays null, matching the unexpanded response. + # Serializing None emits an object built from the nested + # serializer's field defaults instead. + response[expand] = None else: # You might need to handle this case differently response[expand] = getattr(instance, f"{expand}_id", None) @@ -184,6 +173,7 @@ def to_representation(self, instance): if "issue_attachments" in self.fields or "issue_attachments" in self.expand: # Import the model here to avoid circular imports from plane.db.models import FileAsset + from . import IssueAttachmentLiteSerializer issue_id = getattr(instance, "id", None) diff --git a/apps/api/plane/tests/contract/api/conftest.py b/apps/api/plane/tests/contract/api/conftest.py new file mode 100644 index 00000000000..2f72d510dca --- /dev/null +++ b/apps/api/plane/tests/contract/api/conftest.py @@ -0,0 +1,32 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +import pytest +from django.core.cache import cache + + +def _clear_api_key_throttle_keys(): + """Delete only the ApiKeyRateThrottle history keys from the shared cache. + + ``ApiKeyRateThrottle.get_cache_key`` returns ``api_key:``, so scoping + the pattern to ``api_key:`` removes just this throttle's entries instead of + wiping unrelated cache state. + """ + cache.delete_pattern("api_key:*") + + +@pytest.fixture(autouse=True) +def _reset_api_key_throttle_cache(): + """Clear the API-key throttle state around every test in this package. + + Every test here authenticates with the same token from the ``api_token`` + fixture, and the request history behind ``ApiKeyRateThrottle`` lives in a + cache the whole session shares. Without this the count leaks across tests + until the suite trips its own rate limit and later tests fail with 429 + regardless of the code under test. Mirrors ``_reset_auth_throttle_cache`` + in ``plane/tests/contract/app/test_authentication.py``. + """ + _clear_api_key_throttle_keys() + yield + _clear_api_key_throttle_keys() diff --git a/apps/api/plane/tests/contract/api/test_projects.py b/apps/api/plane/tests/contract/api/test_projects.py index dd32877118b..c0378ca7ec3 100644 --- a/apps/api/plane/tests/contract/api/test_projects.py +++ b/apps/api/plane/tests/contract/api/test_projects.py @@ -259,3 +259,88 @@ def test_response_still_201_when_broker_dispatch_fails(self, api_key_client, wor # The dispatch was attempted but its failure was swallowed by # transaction.on_commit(robust=True). mocked_activity.delay.assert_called_once() + + +@pytest.mark.contract +class TestProjectListExpand: + """Contract tests for ``?expand=`` on GET /api/v1/workspaces/{slug}/projects/. + + Regression for https://github.com/makeplane/plane/issues/4639. ``updated_by`` + originally came back as a bare UUID because it was missing from the expansion + mapper. Adding it was not enough: ``BaseModel.save`` leaves ``updated_by`` NULL + until a record is first updated, and expanding a null relation emitted ``{}`` + rather than ``null``. + """ + + def get_url(self, workspace_slug): + return f"/api/v1/workspaces/{workspace_slug}/projects/" + + def make_project(self, workspace, user, identifier, updated_by=None): + """Create a project with the audit columns set explicitly. + + ``BaseModel.save()`` overwrites ``created_by``/``updated_by`` from the + current request user (there is none here), so the values have to be + written with a queryset update that bypasses ``save()``. + """ + project = Project.objects.create( + name=f"Project {identifier}", + identifier=identifier, + workspace=workspace, + project_lead=user, + ) + ProjectMember.objects.create(project=project, member=user, role=20) + Project.objects.filter(pk=project.pk).update(created_by=user, updated_by=updated_by) + project.refresh_from_db() + return project + + def get_project(self, response, identifier): + results = response.json()["results"] + match = next((p for p in results if p["identifier"] == identifier), None) + assert match is not None, f"{identifier} missing from results: {[p['identifier'] for p in results]}" + return match + + @pytest.mark.django_db + def test_expand_updated_by_returns_a_user_object(self, api_key_client, workspace, create_user): + """The exact request from the issue: expand=created_by,updated_by,project_lead.""" + self.make_project(workspace, create_user, "EXP", updated_by=create_user) + + response = api_key_client.get( + self.get_url(workspace.slug), + {"expand": "created_by,updated_by,project_lead"}, + ) + + assert response.status_code == status.HTTP_200_OK, f"Got {response.status_code}: {response.data!r}" + payload = self.get_project(response, "EXP") + for field in ("created_by", "updated_by", "project_lead"): + assert isinstance(payload[field], dict), f"{field} was not expanded: {payload[field]!r}" + assert payload[field]["id"] == str(create_user.id) + assert "display_name" in payload[field] + + @pytest.mark.django_db + def test_expand_null_relation_returns_null(self, api_key_client, workspace, create_user): + """A project that has never been edited has updated_by = NULL. + + Expanding it must stay ``null`` rather than becoming an empty object. + """ + project = self.make_project(workspace, create_user, "NEW") + assert project.updated_by_id is None, "fixture precondition: updated_by is null" + + response = api_key_client.get( + self.get_url(workspace.slug), + {"expand": "created_by,updated_by"}, + ) + + assert response.status_code == status.HTTP_200_OK, f"Got {response.status_code}: {response.data!r}" + payload = self.get_project(response, "NEW") + assert payload["updated_by"] is None + assert isinstance(payload["created_by"], dict) + + @pytest.mark.django_db + def test_without_expand_updated_by_stays_a_plain_id(self, api_key_client, workspace, create_user): + self.make_project(workspace, create_user, "RAW", updated_by=create_user) + + response = api_key_client.get(self.get_url(workspace.slug)) + + assert response.status_code == status.HTTP_200_OK, f"Got {response.status_code}: {response.data!r}" + payload = self.get_project(response, "RAW") + assert payload["updated_by"] == str(create_user.id) diff --git a/apps/api/plane/tests/unit/serializers/test_expand.py b/apps/api/plane/tests/unit/serializers/test_expand.py new file mode 100644 index 00000000000..ecc6da2b86d --- /dev/null +++ b/apps/api/plane/tests/unit/serializers/test_expand.py @@ -0,0 +1,180 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +"""Regression tests for the ``expand`` query parameter. + +See https://github.com/makeplane/plane/issues/4639 -- ``?expand=updated_by`` +returned the bare UUID instead of a user object, and once the key was added to +the mapper it returned an empty object for the (very common) case of a record +that has never been updated. +""" + +from uuid import uuid4 + +import pytest + +from plane.api.serializers import ModuleLiteSerializer, ProjectSerializer +from plane.app.serializers import IssueSerializer as AppIssueSerializer +from plane.app.serializers.base import get_expansion_mapper +from plane.app.serializers.project import ProjectListSerializer +from plane.db.models import Issue, Module, ModuleMember, Project, User, Workspace + +# The three fields from the request in issue #4639. +AUDIT_EXPANDS = ["created_by", "updated_by", "project_lead"] + + +def build_user(): + return User( + id=uuid4(), + email="ada@plane.so", + first_name="Ada", + last_name="Lovelace", + display_name="ada", + ) + + +def build_project(**kwargs): + workspace = Workspace(id=uuid4(), name="Test Workspace", slug="test-workspace") + return Project( + id=uuid4(), + name="Test Project", + identifier="TEST", + workspace=workspace, + **kwargs, + ) + + +@pytest.mark.unit +class TestPublicAPIExpand: + """``/api/v1/`` -- plane.api.serializers.base.BaseSerializer.""" + + def test_updated_by_expands_to_a_user_object(self): + """The original report: updated_by came back as a bare UUID.""" + user = build_user() + project = build_project(created_by=user, updated_by=user, project_lead=user) + + data = ProjectSerializer(project, expand=AUDIT_EXPANDS).data + + for field in AUDIT_EXPANDS: + assert isinstance(data[field], dict), f"{field} was not expanded: {data[field]!r}" + assert data[field]["id"] == user.id + assert data[field]["display_name"] == "ada" + + def test_null_relation_expands_to_null_not_an_empty_object(self): + """updated_by is null until a record is first updated (BaseModel.save). + + Expanding it used to emit ``{}`` -- the nested serializer built from its + own defaults -- which does not match the unexpanded response. + """ + user = build_user() + project = build_project(created_by=user, updated_by=None, project_lead=None) + + data = ProjectSerializer(project, expand=AUDIT_EXPANDS).data + + assert data["updated_by"] is None + assert data["project_lead"] is None + assert isinstance(data["created_by"], dict) + + def test_expand_does_not_change_the_unexpanded_contract(self): + user = build_user() + project = build_project(created_by=user, updated_by=None, project_lead=None) + + data = ProjectSerializer(project).data + + assert data["created_by"] == user.id + assert data["updated_by"] is None + assert data["project_lead"] is None + + def test_expand_name_that_is_not_a_relation_is_not_a_field(self): + project = build_project(created_by=build_user()) + + data = ProjectSerializer(project, expand=["not_a_field"]).data + + assert "not_a_field" not in data + + def test_to_many_relation_expands_to_a_list_of_objects(self, db): + """Guards the to-many arm of the dispatch. + + Arity is now read off the ORM object (a related manager) rather than + guessed from the already-serialized value, so this pins that to-many + expansion keeps working. + """ + user = User.objects.create(email="member@plane.so", display_name="member") + workspace = Workspace.objects.create(name="Test Workspace", slug="test-workspace", owner=user) + project = Project.objects.create(name="Test Project", identifier="TEST", workspace=workspace) + module = Module.objects.create(name="Test Module", project=project, workspace=workspace) + ModuleMember.objects.create(module=module, member=user, project=project, workspace=workspace) + + data = ModuleLiteSerializer(module, expand=["members"]).data + + assert isinstance(data["members"], list) + assert [m["id"] for m in data["members"]] == [user.id] + + def test_audit_fields_expand_identically(self): + """created_by and updated_by must stay in lockstep. + + #4639 happened because only created_by was in the mapper. + """ + user = build_user() + project = build_project(created_by=user, updated_by=user) + + data = ProjectSerializer(project, expand=["created_by", "updated_by"]).data + + assert data["created_by"] == data["updated_by"] + assert data["created_by"]["id"] == user.id + + +@pytest.mark.unit +class TestAppAPIExpand: + """``/api/`` (internal) -- plane.app.serializers.base.DynamicBaseSerializer.""" + + def build_issue(self, created_by, updated_by): + workspace = Workspace(id=uuid4(), name="Test Workspace", slug="test-workspace") + project = Project(id=uuid4(), name="Test Project", identifier="TEST", workspace=workspace) + return Issue( + id=uuid4(), + name="Test work item", + project=project, + workspace=workspace, + created_by=created_by, + updated_by=updated_by, + ) + + def test_updated_by_expands_to_a_user_object(self): + user = build_user() + issue = self.build_issue(created_by=user, updated_by=user) + + data = AppIssueSerializer(issue, expand=["created_by", "updated_by"]).data + + assert isinstance(data["updated_by"], dict), f"not expanded: {data['updated_by']!r}" + assert data["updated_by"]["id"] == user.id + + def test_null_relation_expands_to_null(self): + """The app UserLiteSerializer leaves most fields writable, so a null + relation used to serialize into a fabricated user with blank names.""" + user = build_user() + issue = self.build_issue(created_by=user, updated_by=None) + + data = AppIssueSerializer(issue, expand=["created_by", "updated_by"]).data + + assert data["updated_by"] is None + + def test_expand_naming_a_method_field_keeps_the_serializer_value(self, db): + """`members` is a SerializerMethodField on ProjectListSerializer and not a + relation on Project at all. Resolving it used to raise AttributeError and + return a 500; the value the serializer produced must survive instead.""" + workspace = Workspace(id=uuid4(), name="Test Workspace", slug="test-workspace") + project = Project(id=uuid4(), name="Test Project", identifier="TEST", workspace=workspace) + + data = ProjectListSerializer(project, expand=["members"]).data + + assert data["members"] == [] + + def test_mapper_is_shared_by_both_call_sites(self): + """_filter_fields and to_representation had drifted apart, which is how + updated_by ended up in one mapper and not the other.""" + mapper = get_expansion_mapper() + + assert "created_by" in mapper + assert "updated_by" in mapper diff --git a/apps/api/plane/utils/openapi/parameters.py b/apps/api/plane/utils/openapi/parameters.py index 2812892ec91..51f51640896 100644 --- a/apps/api/plane/utils/openapi/parameters.py +++ b/apps/api/plane/utils/openapi/parameters.py @@ -480,13 +480,28 @@ name="expand", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, - description="Comma-separated list of related fields to expand in response", + description=( + "Comma-separated list of related fields to expand in response. An expanded " + "single-valued relation is returned as a nested object instead of an id, and " + "stays `null` when it is not set. Which values apply depends on the resource: " + "`created_by` and `updated_by` on most resources; `project`, `workspace`, " + "`project_lead`, `default_assignee`, `owned_by`, `actor`, `state`, " + "`estimate_point`, `issue`, `user`; and on work items `parent`, `assignees` " + "and `labels`. Only pass names from this list -- a value that is a plain " + "field on the resource rather than a relation is not expandable, and passing " + "it clears that field in the response." + ), required=False, examples=[ + OpenApiExample( + name="Expand audit users", + value="created_by,updated_by", + description="Include full user details for who created and last updated the record", + ), OpenApiExample( name="Expand assignees", value="assignees", - description="Include full assignee details", + description="Include full assignee details (work items)", ), OpenApiExample( name="Multiple expansions",