From e89f6f45147c548a25e17f6428a6b3df796cd08d Mon Sep 17 00:00:00 2001 From: Ben Dichter Date: Wed, 6 May 2026 20:04:44 -0400 Subject: [PATCH 1/5] Add owner: operator to advanced search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filters dandisets to those owned by a given user. The value is matched case-insensitively against User.username OR User.email. The special form `owner:me` resolves to the requesting user (consistent with the existing ?user=me query parameter) and returns 400 if the request is anonymous. Implementation reuses the existing `get_owned_dandisets()` permission helper. We pass `with_superuser=False` so `owner:admin` returns only what admin explicitly owns — guardian's default would otherwise inflate to the entire archive for any superuser. Unknown users return zero results (not an error): a search for a nonexistent owner is a valid 0-hit query. Tests cover username/email lookup, case-insensitivity, unknown user, `owner:me` for an authenticated user, anonymous `owner:me` → 400, the superuser non-inflation guarantee, and combination with other operators. OpenAPI help text and the frontend operator popover updated. --- dandiapi/api/services/search/filters.py | 35 ++++++- dandiapi/api/services/search/parser.py | 1 + dandiapi/api/tests/test_dandiset.py | 111 +++++++++++++++++++++ dandiapi/api/tests/test_search_parser.py | 6 ++ dandiapi/api/views/serializers.py | 4 +- web/src/components/DandisetSearchField.vue | 1 + 6 files changed, 155 insertions(+), 3 deletions(-) diff --git a/dandiapi/api/services/search/filters.py b/dandiapi/api/services/search/filters.py index db3c0cf7c..1b5af3534 100644 --- a/dandiapi/api/services/search/filters.py +++ b/dandiapi/api/services/search/filters.py @@ -6,14 +6,16 @@ import re from typing import TYPE_CHECKING -from django.db.models import OuterRef, Subquery +from django.contrib.auth.models import User +from django.db.models import OuterRef, Q, Subquery from dandiapi.api.models import Version +from dandiapi.api.services.permissions.dandiset import get_owned_dandisets from dandiapi.api.services.search.parser import SearchSyntaxError from dandiapi.search.models import AssetSearch if TYPE_CHECKING: - from django.contrib.auth.models import AnonymousUser, User + from django.contrib.auth.models import AnonymousUser from django.db.models import QuerySet from dandiapi.api.models import Dandiset @@ -39,6 +41,7 @@ } ) _ASSET_OPS = frozenset({'species', 'approach', 'technique', 'standard', 'file_type'}) +_OWNER_OPS = frozenset({'owner'}) def _annotate_latest_version_modified(queryset): @@ -104,6 +107,32 @@ def _apply_asset_filter(queryset, operator: str, value: str): raise ValueError(f'unknown asset operator: {operator}') # pragma: no cover +def _apply_owner_filter( + queryset: QuerySet[Dandiset], value: str, *, request_user: User | AnonymousUser +) -> QuerySet[Dandiset]: + """Filter dandisets to those owned by the given username/email. + + `owner:me` resolves to the requesting user; otherwise we look up the User + by exact (case-insensitive) username or email. Unknown user → empty result + (not an error — searching for a nonexistent owner is a valid 0-hit query). + `with_superuser=False` so `owner:some_admin` returns only what they + actually own, not the entire archive. + """ + if value.lower() == 'me': + if request_user.is_anonymous: + raise SearchSyntaxError( + 'owner:me requires authentication. Sign in or specify a username.' + ) + owner_pks = get_owned_dandisets(request_user, include_superusers=False).values('pk') + return queryset.filter(pk__in=owner_pks) + + matched_user = User.objects.filter(Q(username__iexact=value) | Q(email__iexact=value)).first() + if matched_user is None: + return queryset.none() + owner_pks = get_owned_dandisets(matched_user, include_superusers=False).values('pk') + return queryset.filter(pk__in=owner_pks) + + _MODIFIED_ALIAS = '_search_latest_version_modified' _PUBLISHED_ALIAS = '_search_latest_published_created' @@ -174,6 +203,8 @@ def apply_search_filters( if asset_qs is None: asset_qs = AssetSearch.objects.visible_to(user) asset_qs = _apply_asset_filter(asset_qs, key, value) + elif key in _OWNER_OPS: + queryset = _apply_owner_filter(queryset, value, request_user=user) if asset_qs is not None: # NOTE perf: jsonb_path_exists with a runtime-built jsonpath cannot diff --git a/dandiapi/api/services/search/parser.py b/dandiapi/api/services/search/parser.py index 817642341..37a012788 100644 --- a/dandiapi/api/services/search/parser.py +++ b/dandiapi/api/services/search/parser.py @@ -31,6 +31,7 @@ 'technique', 'standard', 'file_type', + 'owner', } ) diff --git a/dandiapi/api/tests/test_dandiset.py b/dandiapi/api/tests/test_dandiset.py index 712399fc3..be50f0eb1 100644 --- a/dandiapi/api/tests/test_dandiset.py +++ b/dandiapi/api/tests/test_dandiset.py @@ -2086,3 +2086,114 @@ def test_advanced_search_species_respects_embargo_visibility(api_client): # Anonymous request: embargoed must be filtered out. assert _search_ids(api_client, 'species:mouse') == {open_ds.identifier} + + +# --- owner: operator ----------------------------------------------------------------------------- + + +@pytest.mark.ai_generated +@pytest.mark.django_db +def test_advanced_search_owner_by_username_returns_owned_dandisets(api_client): + alice = UserFactory.create(username='alice', email='alice@example.com') + bob = UserFactory.create(username='bob', email='bob@example.com') + alice_ds = DandisetFactory.create(owners=[alice]) + bob_ds = DandisetFactory.create(owners=[bob]) + DraftVersionFactory.create(dandiset=alice_ds) + DraftVersionFactory.create(dandiset=bob_ds) + + assert _search_ids(api_client, 'owner:alice') == {alice_ds.identifier} + assert _search_ids(api_client, 'owner:bob') == {bob_ds.identifier} + + +@pytest.mark.ai_generated +@pytest.mark.django_db +def test_advanced_search_owner_by_email_matches(api_client): + alice = UserFactory.create(username='alice', email='alice@example.com') + alice_ds = DandisetFactory.create(owners=[alice]) + DraftVersionFactory.create(dandiset=alice_ds) + + assert _search_ids(api_client, 'owner:alice@example.com') == {alice_ds.identifier} + + +@pytest.mark.ai_generated +@pytest.mark.django_db +def test_advanced_search_owner_lookup_is_case_insensitive(api_client): + alice = UserFactory.create(username='Alice', email='Alice@Example.com') + alice_ds = DandisetFactory.create(owners=[alice]) + DraftVersionFactory.create(dandiset=alice_ds) + + assert _search_ids(api_client, 'owner:alice') == {alice_ds.identifier} + assert _search_ids(api_client, 'owner:ALICE@example.COM') == {alice_ds.identifier} + + +@pytest.mark.ai_generated +@pytest.mark.django_db +def test_advanced_search_owner_unknown_user_returns_zero(api_client): + DraftVersionFactory.create(dandiset=DandisetFactory.create()) + # No SearchSyntaxError — a search for a nonexistent owner is a valid + # zero-hit query, not a malformed query. + assert _search_ids(api_client, 'owner:no_such_user_anywhere') == set() + + +@pytest.mark.ai_generated +@pytest.mark.django_db +def test_advanced_search_owner_me_resolves_to_authenticated_user(api_client): + alice = UserFactory.create() + bob = UserFactory.create() + alice_ds = DandisetFactory.create(owners=[alice]) + bob_ds = DandisetFactory.create(owners=[bob]) + DraftVersionFactory.create(dandiset=alice_ds) + DraftVersionFactory.create(dandiset=bob_ds) + + api_client.force_authenticate(user=alice) + assert _search_ids(api_client, 'owner:me') == {alice_ds.identifier} + + +@pytest.mark.ai_generated +@pytest.mark.django_db +def test_advanced_search_owner_me_anonymous_returns_400(api_client): + response = api_client.get( + '/api/dandisets/', + {'draft': 'true', 'empty': 'true', 'search': 'owner:me'}, + ) + assert response.status_code == 400 + assert 'requires authentication' in response.json()['search'] + + +@pytest.mark.ai_generated +@pytest.mark.django_db +def test_advanced_search_owner_does_not_inflate_to_superuser_archive(api_client): + # Guardian's get_objects_for_user(with_superuser=True) returns ALL objects + # for superusers — wrong semantics for owner: searches. We pass + # with_superuser=False so `owner:admin` returns only what admin + # explicitly owns, not the entire archive. + admin = UserFactory.create(username='admin', is_superuser=True) + other = UserFactory.create() + DraftVersionFactory.create(dandiset=DandisetFactory.create(owners=[other])) + admin_owned = DandisetFactory.create(owners=[admin]) + DraftVersionFactory.create(dandiset=admin_owned) + + assert _search_ids(api_client, 'owner:admin') == {admin_owned.identifier} + + +@pytest.mark.ai_generated +@pytest.mark.django_db +def test_advanced_search_owner_combines_with_other_operators(api_client): + alice = UserFactory.create(username='alice') + bob = UserFactory.create(username='bob') + alice_old = DandisetFactory.create(owners=[alice]) + alice_new = DandisetFactory.create(owners=[alice]) + bob_new = DandisetFactory.create(owners=[bob]) + for ds in (alice_old, alice_new, bob_new): + DraftVersionFactory.create(dandiset=ds) + + cutoff = timezone.now() - datetime.timedelta(days=1) + Dandiset.objects.filter(pk=alice_old.pk).update( + created=cutoff - datetime.timedelta(days=30) + ) + + after_str = (cutoff + datetime.timedelta(seconds=1)).date().isoformat() + # Only alice_new satisfies BOTH owner:alice AND created_after. + assert _search_ids(api_client, f'owner:alice created_after:{after_str}') == { + alice_new.identifier + } diff --git a/dandiapi/api/tests/test_search_parser.py b/dandiapi/api/tests/test_search_parser.py index 2ab5b42f8..fa99496a5 100644 --- a/dandiapi/api/tests/test_search_parser.py +++ b/dandiapi/api/tests/test_search_parser.py @@ -45,6 +45,10 @@ # Quoted token that *looks* like an operator is treated as free text — # this is the documented escape hatch for searching for a literal colon. ('"foo:bar" hippocampus', ['foo:bar', 'hippocampus'], []), + # Owner operator + ('owner:jdoe', [], [('owner', 'jdoe')]), + # Owner with email value (the parser doesn't validate the value shape) + ('owner:user@example.com', [], [('owner', 'user@example.com')]), ], ids=[ 'empty', @@ -57,6 +61,8 @@ 'repeated-operator-key', 'special-chars-in-quoted-value', 'quoted-operator-like-token-is-free-text', + 'owner-username', + 'owner-email', ], ) def test_parse_search(query, expected_free_text, expected_operators): diff --git a/dandiapi/api/views/serializers.py b/dandiapi/api/views/serializers.py index 0d64477c8..a80c1cc80 100644 --- a/dandiapi/api/views/serializers.py +++ b/dandiapi/api/views/serializers.py @@ -311,7 +311,9 @@ class DandisetQueryParameterSerializer(serializers.Serializer): 'published_before, published_after (all take YYYY-MM-DD); ' 'species, approach, technique, standard (case-insensitive ' 'substring against the corresponding asset_metadata array); ' - 'file_type (nwb, image, text, video — or any MIME prefix). ' + 'file_type (nwb, image, text, video — or any MIME prefix); ' + 'owner (exact username or email; "owner:me" resolves to the ' + 'requesting user). ' 'Invalid syntax returns HTTP 400 with the offending token; ' 'unknown operators get a "Did you mean?" suggestion.' ), diff --git a/web/src/components/DandisetSearchField.vue b/web/src/components/DandisetSearchField.vue index 5b3b5a6db..557d47644 100644 --- a/web/src/components/DandisetSearchField.vue +++ b/web/src/components/DandisetSearchField.vue @@ -95,6 +95,7 @@ const operatorHelp = [ { example: 'technique:"patch clamp"', description: 'Has assets using a measurement technique' }, { example: 'standard:nwb', description: 'Has assets in a data standard' }, { example: 'file_type:nwb', description: 'Has assets of a file type (nwb, image, text, video)' }, + { example: 'owner:jdoe', description: 'Owned by a user (username/email; or "owner:me")' }, ]; function updateSearch(search: string) { From fd0567b583bc581ce051434f9b078adf676b7ce1 Mon Sep 17 00:00:00 2001 From: Ben Dichter Date: Wed, 6 May 2026 20:13:16 -0400 Subject: [PATCH 2/5] owner: also match by display name (first/last/full) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real users encounter the dandiset list with owners shown by display name (e.g. "Super User"), not by username. Searching that string was returning 0 because the lookup only matched username/email. Now matches case-insensitively against username, email, first_name, last_name, OR "first_name last_name" — so owner:"Super User" works the same as owner:ben.dichter@gmail.com. Multiple users may match (e.g. shared last name); we union dandisets owned by any of them via a direct DandisetUserObjectPermission query. Updated OpenAPI help text and the frontend popover example to `owner:"Jane Doe"` so users discover the new shape. --- dandiapi/api/services/search/filters.py | 48 ++++++++++++++++------ dandiapi/api/tests/test_dandiset.py | 38 +++++++++++++++++ dandiapi/api/views/serializers.py | 3 +- web/src/components/DandisetSearchField.vue | 2 +- 4 files changed, 76 insertions(+), 15 deletions(-) diff --git a/dandiapi/api/services/search/filters.py b/dandiapi/api/services/search/filters.py index 1b5af3534..493e5b289 100644 --- a/dandiapi/api/services/search/filters.py +++ b/dandiapi/api/services/search/filters.py @@ -7,9 +7,11 @@ from typing import TYPE_CHECKING from django.contrib.auth.models import User -from django.db.models import OuterRef, Q, Subquery +from django.db.models import OuterRef, Q, Subquery, Value +from django.db.models.functions import Concat from dandiapi.api.models import Version +from dandiapi.api.models.dandiset import DandisetUserObjectPermission from dandiapi.api.services.permissions.dandiset import get_owned_dandisets from dandiapi.api.services.search.parser import SearchSyntaxError from dandiapi.search.models import AssetSearch @@ -110,13 +112,23 @@ def _apply_asset_filter(queryset, operator: str, value: str): def _apply_owner_filter( queryset: QuerySet[Dandiset], value: str, *, request_user: User | AnonymousUser ) -> QuerySet[Dandiset]: - """Filter dandisets to those owned by the given username/email. - - `owner:me` resolves to the requesting user; otherwise we look up the User - by exact (case-insensitive) username or email. Unknown user → empty result - (not an error — searching for a nonexistent owner is a valid 0-hit query). - `with_superuser=False` so `owner:some_admin` returns only what they - actually own, not the entire archive. + """Filter dandisets to those owned by the given user identifier. + + `owner:me` resolves to the requesting user; otherwise we match `value` + case-insensitively against: + - User.username + - User.email + - User.first_name + - User.last_name + - "first_name last_name" (so the display name shown in the UI works) + + Multiple users may match (common when only a first or last name is given); + we union dandisets owned by any of them. Unknown user → empty result (not + an error — a search for a nonexistent owner is a valid 0-hit query). + + Direct query against `DandisetUserObjectPermission` rather than guardian's + `get_objects_for_user` so we can intersect across multiple matched users + in a single query, and to bypass the superuser-sees-everything default. """ if value.lower() == 'me': if request_user.is_anonymous: @@ -126,11 +138,21 @@ def _apply_owner_filter( owner_pks = get_owned_dandisets(request_user, include_superusers=False).values('pk') return queryset.filter(pk__in=owner_pks) - matched_user = User.objects.filter(Q(username__iexact=value) | Q(email__iexact=value)).first() - if matched_user is None: - return queryset.none() - owner_pks = get_owned_dandisets(matched_user, include_superusers=False).values('pk') - return queryset.filter(pk__in=owner_pks) + matched_user_pks = ( + User.objects.annotate(_full_name=Concat('first_name', Value(' '), 'last_name')) + .filter( + Q(username__iexact=value) + | Q(email__iexact=value) + | Q(first_name__iexact=value) + | Q(last_name__iexact=value) + | Q(_full_name__iexact=value) + ) + .values_list('pk', flat=True) + ) + owned_pks = DandisetUserObjectPermission.objects.filter( + user__in=matched_user_pks, permission__codename='owner' + ).values('content_object') + return queryset.filter(pk__in=owned_pks) _MODIFIED_ALIAS = '_search_latest_version_modified' diff --git a/dandiapi/api/tests/test_dandiset.py b/dandiapi/api/tests/test_dandiset.py index be50f0eb1..6253a3a30 100644 --- a/dandiapi/api/tests/test_dandiset.py +++ b/dandiapi/api/tests/test_dandiset.py @@ -2197,3 +2197,41 @@ def test_advanced_search_owner_combines_with_other_operators(api_client): assert _search_ids(api_client, f'owner:alice created_after:{after_str}') == { alice_new.identifier } + + +@pytest.mark.ai_generated +@pytest.mark.django_db +def test_advanced_search_owner_by_full_name_matches(api_client): + # The dandiset list shows owners by display name (first + ' ' + last). + # `owner:"Super User"` should match a user with that full name even + # though their username is an email address. + user = UserFactory.create( + username='ben.dichter@gmail.com', + email='ben.dichter@gmail.com', + first_name='Super', + last_name='User', + ) + user_ds = DandisetFactory.create(owners=[user]) + DraftVersionFactory.create(dandiset=user_ds) + + assert _search_ids(api_client, 'owner:"Super User"') == {user_ds.identifier} + # First-name-only and last-name-only also work. + assert _search_ids(api_client, 'owner:Super') == {user_ds.identifier} + assert _search_ids(api_client, 'owner:User') == {user_ds.identifier} + + +@pytest.mark.ai_generated +@pytest.mark.django_db +def test_advanced_search_owner_unions_multiple_matched_users(api_client): + # Two distinct users share a last name. `owner:Smith` should return + # dandisets owned by either of them. + alice = UserFactory.create(username='alice', last_name='Smith') + bob = UserFactory.create(username='bob', last_name='Smith') + eve = UserFactory.create(username='eve', last_name='Jones') + alice_ds = DandisetFactory.create(owners=[alice]) + bob_ds = DandisetFactory.create(owners=[bob]) + DandisetFactory.create(owners=[eve]) + for ds in Dandiset.objects.all(): + DraftVersionFactory.create(dandiset=ds) + + assert _search_ids(api_client, 'owner:Smith') == {alice_ds.identifier, bob_ds.identifier} diff --git a/dandiapi/api/views/serializers.py b/dandiapi/api/views/serializers.py index a80c1cc80..45e85bf6a 100644 --- a/dandiapi/api/views/serializers.py +++ b/dandiapi/api/views/serializers.py @@ -312,7 +312,8 @@ class DandisetQueryParameterSerializer(serializers.Serializer): 'species, approach, technique, standard (case-insensitive ' 'substring against the corresponding asset_metadata array); ' 'file_type (nwb, image, text, video — or any MIME prefix); ' - 'owner (exact username or email; "owner:me" resolves to the ' + 'owner (case-insensitive match against username, email, first ' + 'name, last name, or "first last"; "owner:me" resolves to the ' 'requesting user). ' 'Invalid syntax returns HTTP 400 with the offending token; ' 'unknown operators get a "Did you mean?" suggestion.' diff --git a/web/src/components/DandisetSearchField.vue b/web/src/components/DandisetSearchField.vue index 557d47644..fc1bc8625 100644 --- a/web/src/components/DandisetSearchField.vue +++ b/web/src/components/DandisetSearchField.vue @@ -95,7 +95,7 @@ const operatorHelp = [ { example: 'technique:"patch clamp"', description: 'Has assets using a measurement technique' }, { example: 'standard:nwb', description: 'Has assets in a data standard' }, { example: 'file_type:nwb', description: 'Has assets of a file type (nwb, image, text, video)' }, - { example: 'owner:jdoe', description: 'Owned by a user (username/email; or "owner:me")' }, + { example: 'owner:"Jane Doe"', description: 'Owned by a user (name, username, or email; or "owner:me")' }, ]; function updateSearch(search: string) { From 74fcc5b8480350bcc0427af609cbb8acf11261e4 Mon Sep 17 00:00:00 2001 From: Ben Dichter Date: Fri, 8 May 2026 08:59:38 -0400 Subject: [PATCH 3/5] Apply ruff format to test_dandiset.py --- dandiapi/api/tests/test_dandiset.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/dandiapi/api/tests/test_dandiset.py b/dandiapi/api/tests/test_dandiset.py index 6253a3a30..67618faf7 100644 --- a/dandiapi/api/tests/test_dandiset.py +++ b/dandiapi/api/tests/test_dandiset.py @@ -2188,9 +2188,7 @@ def test_advanced_search_owner_combines_with_other_operators(api_client): DraftVersionFactory.create(dandiset=ds) cutoff = timezone.now() - datetime.timedelta(days=1) - Dandiset.objects.filter(pk=alice_old.pk).update( - created=cutoff - datetime.timedelta(days=30) - ) + Dandiset.objects.filter(pk=alice_old.pk).update(created=cutoff - datetime.timedelta(days=30)) after_str = (cutoff + datetime.timedelta(seconds=1)).date().isoformat() # Only alice_new satisfies BOTH owner:alice AND created_after. From a55ec5643ee1e844c84f21ed3d70aaf0f3e705e4 Mon Sep 17 00:00:00 2001 From: Ben Dichter Date: Fri, 8 May 2026 12:12:18 -0400 Subject: [PATCH 4/5] owner: keep owner:me magic; add quoted-form escape; consolidate tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 review feedback on #2821: - @yarikoptic flagged that owner:me silently shadows a real user named "Me". Fix: distinguish quoted vs unquoted at the parser level. Unquoted owner:me → magic alias for the requesting user. Quoted owner:"me" → literal lookup (matches a user whose first/last name is "Me"). Same pattern lets owner:"Me Someoneyou" reach the literal full-name match while keeping the convenient owner:me shortcut. Implementation: ParsedSearch.operators is now a list of `Operator` dataclasses (key, value, quoted) instead of bare tuples. Filters consume the new shape and the owner filter switches on the quoted flag. - Replaced personal email (ben.dichter@gmail.com) in the full-name test fixture with a generic example user. - Consolidated 10 small owner-tests into 3 denser ones that share setup per @yarikoptic's "make each test matter more" feedback. Coverage is unchanged (every documented lookup path is asserted; cross-key AND with another operator; multi-user union via shared last name; unknown user → 0; superuser non-inflation; owner:me magic; owner:"me" literal-escape; anonymous owner:me → 400). DB setup runs ~3x instead of ~10x. Updated OpenAPI help text and the search popover to mention the owner:me alias and the quoted-escape. --- dandiapi/api/services/search/filters.py | 36 +++-- dandiapi/api/services/search/parser.py | 20 ++- dandiapi/api/tests/test_dandiset.py | 174 +++++++++------------ dandiapi/api/tests/test_search_parser.py | 37 +++-- dandiapi/api/views/serializers.py | 5 +- web/src/components/DandisetSearchField.vue | 2 +- 6 files changed, 141 insertions(+), 133 deletions(-) diff --git a/dandiapi/api/services/search/filters.py b/dandiapi/api/services/search/filters.py index 493e5b289..ec01a1c9f 100644 --- a/dandiapi/api/services/search/filters.py +++ b/dandiapi/api/services/search/filters.py @@ -110,30 +110,35 @@ def _apply_asset_filter(queryset, operator: str, value: str): def _apply_owner_filter( - queryset: QuerySet[Dandiset], value: str, *, request_user: User | AnonymousUser + queryset: QuerySet[Dandiset], + value: str, + *, + quoted: bool, + request_user: User | AnonymousUser, ) -> QuerySet[Dandiset]: """Filter dandisets to those owned by the given user identifier. - `owner:me` resolves to the requesting user; otherwise we match `value` - case-insensitively against: - - User.username - - User.email - - User.first_name - - User.last_name - - "first_name last_name" (so the display name shown in the UI works) + The unquoted token `owner:me` resolves to the requesting user. To search + for a literal user named "Me" instead, quote the value: `owner:"me"`. + The quoted form bypasses the magic alias and goes straight to the + case-insensitive lookup. + Lookups (for any non-magic value) match case-insensitively against + `User.username`, `User.email`, `User.first_name`, `User.last_name`, or + `"first_name last_name"` (so the display name shown in the UI works). Multiple users may match (common when only a first or last name is given); we union dandisets owned by any of them. Unknown user → empty result (not an error — a search for a nonexistent owner is a valid 0-hit query). Direct query against `DandisetUserObjectPermission` rather than guardian's - `get_objects_for_user` so we can intersect across multiple matched users - in a single query, and to bypass the superuser-sees-everything default. + `get_objects_for_user` so we can union across multiple matched users in a + single query, and to bypass the superuser-sees-everything default. """ - if value.lower() == 'me': + if not quoted and value.lower() == 'me': if request_user.is_anonymous: raise SearchSyntaxError( - 'owner:me requires authentication. Sign in or specify a username.' + 'owner:me requires authentication. Sign in, or use owner:"me" ' + 'to search for a literal user named Me.' ) owner_pks = get_owned_dandisets(request_user, include_superusers=False).values('pk') return queryset.filter(pk__in=owner_pks) @@ -208,8 +213,9 @@ def apply_search_filters( asset_qs = None annotated: set[str] = set() - for key, raw_value in parsed.operators: - value = raw_value.strip() + for op in parsed.operators: + key = op.key + value = op.value.strip() if not value: raise SearchSyntaxError(f'Operator "{key}" requires a value (e.g. {key}:something).') @@ -226,7 +232,7 @@ def apply_search_filters( asset_qs = AssetSearch.objects.visible_to(user) asset_qs = _apply_asset_filter(asset_qs, key, value) elif key in _OWNER_OPS: - queryset = _apply_owner_filter(queryset, value, request_user=user) + queryset = _apply_owner_filter(queryset, value, quoted=op.quoted, request_user=user) if asset_qs is not None: # NOTE perf: jsonb_path_exists with a runtime-built jsonpath cannot diff --git a/dandiapi/api/services/search/parser.py b/dandiapi/api/services/search/parser.py index 37a012788..d20367284 100644 --- a/dandiapi/api/services/search/parser.py +++ b/dandiapi/api/services/search/parser.py @@ -61,10 +61,24 @@ class SearchSyntaxError(ValueError): """Raised when a search query can't be parsed.""" +@dataclass +class Operator: + """One parsed `key:value` operator. + + `quoted` records whether the value came from a quoted form (`key:"value"`). + Most operators ignore this, but it lets `owner:` distinguish the magic + `owner:me` (current user) from `owner:"me"` (literal user named "Me"). + """ + + key: str + value: str + quoted: bool + + @dataclass class ParsedSearch: free_text: list[str] = field(default_factory=list) - operators: list[tuple[str, str]] = field(default_factory=list) + operators: list[Operator] = field(default_factory=list) def _check_balanced_quotes(query: str) -> None: @@ -100,7 +114,7 @@ def parse_search(query: str) -> ParsedSearch: for match in _TOKEN_RE.finditer(query): if (key := match.group('op_key')) is not None: _validate_operator_key(key) - parsed.operators.append((key, match.group('op_qval'))) + parsed.operators.append(Operator(key, match.group('op_qval'), quoted=True)) elif (free := match.group('free_quoted')) is not None: parsed.free_text.append(free) else: @@ -108,7 +122,7 @@ def parse_search(query: str) -> ParsedSearch: if op_match := _BARE_OP_RE.match(bare): key = op_match.group(1) _validate_operator_key(key) - parsed.operators.append((key, op_match.group(2))) + parsed.operators.append(Operator(key, op_match.group(2), quoted=False)) else: parsed.free_text.append(bare) return parsed diff --git a/dandiapi/api/tests/test_dandiset.py b/dandiapi/api/tests/test_dandiset.py index 67618faf7..0ebb4b920 100644 --- a/dandiapi/api/tests/test_dandiset.py +++ b/dandiapi/api/tests/test_dandiset.py @@ -2093,65 +2093,83 @@ def test_advanced_search_species_respects_embargo_visibility(api_client): @pytest.mark.ai_generated @pytest.mark.django_db -def test_advanced_search_owner_by_username_returns_owned_dandisets(api_client): - alice = UserFactory.create(username='alice', email='alice@example.com') - bob = UserFactory.create(username='bob', email='bob@example.com') - alice_ds = DandisetFactory.create(owners=[alice]) - bob_ds = DandisetFactory.create(owners=[bob]) - DraftVersionFactory.create(dandiset=alice_ds) - DraftVersionFactory.create(dandiset=bob_ds) - - assert _search_ids(api_client, 'owner:alice') == {alice_ds.identifier} - assert _search_ids(api_client, 'owner:bob') == {bob_ds.identifier} +def test_advanced_search_owner_lookup_paths_and_combinations(api_client): + """One setup, many assertions for the owner: operator. + Resolves users by every documented lookup path, unions across multiple + matched users, returns 0 for unknown values, is case-insensitive, and + combines correctly with other operators (cross-key AND on the same + dandiset). + """ + # Three users with overlapping last names so we can exercise every lookup + # path AND the multi-user union in a single setup. + alice = UserFactory.create( + username='Alice', email='Alice@Example.com', first_name='Alice', last_name='Smith' + ) + bob = UserFactory.create( + username='bob', email='bob@example.com', first_name='Bob', last_name='Smith' + ) + carol = UserFactory.create( + username='carol', email='carol@example.com', first_name='Carol', last_name='Jones' + ) + alice_old = DandisetFactory.create(owners=[alice]) + alice_new = DandisetFactory.create(owners=[alice]) + bob_ds = DandisetFactory.create(owners=[bob]) + carol_ds = DandisetFactory.create(owners=[carol]) + for ds in (alice_old, alice_new, bob_ds, carol_ds): + DraftVersionFactory.create(dandiset=ds) -@pytest.mark.ai_generated -@pytest.mark.django_db -def test_advanced_search_owner_by_email_matches(api_client): - alice = UserFactory.create(username='alice', email='alice@example.com') - alice_ds = DandisetFactory.create(owners=[alice]) - DraftVersionFactory.create(dandiset=alice_ds) + # Backdate alice_old so we can intersect with a date operator below. + cutoff = timezone.now() - datetime.timedelta(days=1) + Dandiset.objects.filter(pk=alice_old.pk).update(created=cutoff - datetime.timedelta(days=30)) + after_str = (cutoff + datetime.timedelta(seconds=1)).date().isoformat() - assert _search_ids(api_client, 'owner:alice@example.com') == {alice_ds.identifier} + alice_dsets = {alice_old.identifier, alice_new.identifier} + # username (case-insensitive) + assert _search_ids(api_client, 'owner:alice') == alice_dsets + assert _search_ids(api_client, 'owner:ALICE') == alice_dsets -@pytest.mark.ai_generated -@pytest.mark.django_db -def test_advanced_search_owner_lookup_is_case_insensitive(api_client): - alice = UserFactory.create(username='Alice', email='Alice@Example.com') - alice_ds = DandisetFactory.create(owners=[alice]) - DraftVersionFactory.create(dandiset=alice_ds) + # email (case-insensitive) + assert _search_ids(api_client, 'owner:alice@example.com') == alice_dsets + assert _search_ids(api_client, 'owner:ALICE@Example.com') == alice_dsets - assert _search_ids(api_client, 'owner:alice') == {alice_ds.identifier} - assert _search_ids(api_client, 'owner:ALICE@example.COM') == {alice_ds.identifier} + # first / last / full name + assert _search_ids(api_client, 'owner:Bob') == {bob_ds.identifier} + assert _search_ids(api_client, 'owner:Jones') == {carol_ds.identifier} + assert _search_ids(api_client, 'owner:"Carol Jones"') == {carol_ds.identifier} + # union: shared last name returns dandisets from both users + assert _search_ids(api_client, 'owner:Smith') == alice_dsets | {bob_ds.identifier} -@pytest.mark.ai_generated -@pytest.mark.django_db -def test_advanced_search_owner_unknown_user_returns_zero(api_client): - DraftVersionFactory.create(dandiset=DandisetFactory.create()) - # No SearchSyntaxError — a search for a nonexistent owner is a valid - # zero-hit query, not a malformed query. + # unknown user → 0 results, not 400 (a valid 0-hit query) assert _search_ids(api_client, 'owner:no_such_user_anywhere') == set() + # combines with other operators: cross-key AND on the same dandiset. + # Only alice_new satisfies BOTH owner:alice AND created_after. + assert _search_ids(api_client, f'owner:alice created_after:{after_str}') == { + alice_new.identifier + } + @pytest.mark.ai_generated @pytest.mark.django_db -def test_advanced_search_owner_me_resolves_to_authenticated_user(api_client): - alice = UserFactory.create() - bob = UserFactory.create() +def test_advanced_search_owner_me_magic_and_literal_escape(api_client): + """`owner:me` (unquoted) is the magic alias for "the current user". + + Anonymous → 400. To search for a real user named "Me" instead, quote + the value (`owner:"me"`) — the quoted form opts out of the magic and + falls back to the case-insensitive lookup against username / email / + first / last / full name. + """ + alice = UserFactory.create(username='alice') + me_user = UserFactory.create(username='me_actual_user', first_name='Me', last_name='Someoneyou') alice_ds = DandisetFactory.create(owners=[alice]) - bob_ds = DandisetFactory.create(owners=[bob]) + me_ds = DandisetFactory.create(owners=[me_user]) DraftVersionFactory.create(dandiset=alice_ds) - DraftVersionFactory.create(dandiset=bob_ds) - - api_client.force_authenticate(user=alice) - assert _search_ids(api_client, 'owner:me') == {alice_ds.identifier} + DraftVersionFactory.create(dandiset=me_ds) - -@pytest.mark.ai_generated -@pytest.mark.django_db -def test_advanced_search_owner_me_anonymous_returns_400(api_client): + # Anonymous + `owner:me` → 400 with explicit message response = api_client.get( '/api/dandisets/', {'draft': 'true', 'empty': 'true', 'search': 'owner:me'}, @@ -2159,6 +2177,17 @@ def test_advanced_search_owner_me_anonymous_returns_400(api_client): assert response.status_code == 400 assert 'requires authentication' in response.json()['search'] + # Authenticated + unquoted `owner:me` → only alice's dandisets + # (does NOT match the literal user named "Me"). + api_client.force_authenticate(user=alice) + assert _search_ids(api_client, 'owner:me') == {alice_ds.identifier} + + # Authenticated + quoted `owner:"me"` → escapes the magic and matches + # the literal user "Me" by first_name (NOT alice). + assert _search_ids(api_client, 'owner:"me"') == {me_ds.identifier} + # Full display name lookup also works for that user. + assert _search_ids(api_client, 'owner:"Me Someoneyou"') == {me_ds.identifier} + @pytest.mark.ai_generated @pytest.mark.django_db @@ -2174,62 +2203,3 @@ def test_advanced_search_owner_does_not_inflate_to_superuser_archive(api_client) DraftVersionFactory.create(dandiset=admin_owned) assert _search_ids(api_client, 'owner:admin') == {admin_owned.identifier} - - -@pytest.mark.ai_generated -@pytest.mark.django_db -def test_advanced_search_owner_combines_with_other_operators(api_client): - alice = UserFactory.create(username='alice') - bob = UserFactory.create(username='bob') - alice_old = DandisetFactory.create(owners=[alice]) - alice_new = DandisetFactory.create(owners=[alice]) - bob_new = DandisetFactory.create(owners=[bob]) - for ds in (alice_old, alice_new, bob_new): - DraftVersionFactory.create(dandiset=ds) - - cutoff = timezone.now() - datetime.timedelta(days=1) - Dandiset.objects.filter(pk=alice_old.pk).update(created=cutoff - datetime.timedelta(days=30)) - - after_str = (cutoff + datetime.timedelta(seconds=1)).date().isoformat() - # Only alice_new satisfies BOTH owner:alice AND created_after. - assert _search_ids(api_client, f'owner:alice created_after:{after_str}') == { - alice_new.identifier - } - - -@pytest.mark.ai_generated -@pytest.mark.django_db -def test_advanced_search_owner_by_full_name_matches(api_client): - # The dandiset list shows owners by display name (first + ' ' + last). - # `owner:"Super User"` should match a user with that full name even - # though their username is an email address. - user = UserFactory.create( - username='ben.dichter@gmail.com', - email='ben.dichter@gmail.com', - first_name='Super', - last_name='User', - ) - user_ds = DandisetFactory.create(owners=[user]) - DraftVersionFactory.create(dandiset=user_ds) - - assert _search_ids(api_client, 'owner:"Super User"') == {user_ds.identifier} - # First-name-only and last-name-only also work. - assert _search_ids(api_client, 'owner:Super') == {user_ds.identifier} - assert _search_ids(api_client, 'owner:User') == {user_ds.identifier} - - -@pytest.mark.ai_generated -@pytest.mark.django_db -def test_advanced_search_owner_unions_multiple_matched_users(api_client): - # Two distinct users share a last name. `owner:Smith` should return - # dandisets owned by either of them. - alice = UserFactory.create(username='alice', last_name='Smith') - bob = UserFactory.create(username='bob', last_name='Smith') - eve = UserFactory.create(username='eve', last_name='Jones') - alice_ds = DandisetFactory.create(owners=[alice]) - bob_ds = DandisetFactory.create(owners=[bob]) - DandisetFactory.create(owners=[eve]) - for ds in Dandiset.objects.all(): - DraftVersionFactory.create(dandiset=ds) - - assert _search_ids(api_client, 'owner:Smith') == {alice_ds.identifier, bob_ds.identifier} diff --git a/dandiapi/api/tests/test_search_parser.py b/dandiapi/api/tests/test_search_parser.py index fa99496a5..4a72fb36c 100644 --- a/dandiapi/api/tests/test_search_parser.py +++ b/dandiapi/api/tests/test_search_parser.py @@ -3,6 +3,7 @@ import pytest from dandiapi.api.services.search.parser import ( + Operator, SearchSyntaxError, parse_search, ) @@ -10,6 +11,17 @@ pytestmark = pytest.mark.ai_generated +# Convenience aliases so the parametrize table stays readable. +def _u(key: str, value: str) -> Operator: + """Unquoted operator (e.g. parsed from `key:value`).""" + return Operator(key, value, quoted=False) + + +def _q(key: str, value: str) -> Operator: + """Quoted operator (e.g. parsed from `key:"value"`).""" + return Operator(key, value, quoted=True) + + @pytest.mark.parametrize( ('query', 'expected_free_text', 'expected_operators'), [ @@ -22,33 +34,36 @@ ( 'species:mouse created_after:2024-01-01', [], - [('species', 'mouse'), ('created_after', '2024-01-01')], + [_u('species', 'mouse'), _u('created_after', '2024-01-01')], ), # Mixed ( 'place cells species:mouse created_after:2024-01-01 ca1', ['place', 'cells', 'ca1'], - [('species', 'mouse'), ('created_after', '2024-01-01')], + [_u('species', 'mouse'), _u('created_after', '2024-01-01')], ), # Quoted phrase as free text ('"place cells" hippocampus', ['place cells', 'hippocampus'], []), - # Quoted operator value (multi-word) - ('technique:"patch clamp"', [], [('technique', 'patch clamp')]), + # Quoted operator value (multi-word) — `quoted=True` + ('technique:"patch clamp"', [], [_q('technique', 'patch clamp')]), # Repeated operator keeps every entry (AND'd downstream) ( 'species:mouse species:rat', [], - [('species', 'mouse'), ('species', 'rat')], + [_u('species', 'mouse'), _u('species', 'rat')], ), # Special characters preserved inside quoted operator value - ('species:"C57BL/6"', [], [('species', 'C57BL/6')]), + ('species:"C57BL/6"', [], [_q('species', 'C57BL/6')]), # Quoted token that *looks* like an operator is treated as free text — - # this is the documented escape hatch for searching for a literal colon. + # documented escape hatch for searching for a literal colon. ('"foo:bar" hippocampus', ['foo:bar', 'hippocampus'], []), - # Owner operator - ('owner:jdoe', [], [('owner', 'jdoe')]), + # Owner operator (unquoted vs quoted distinguished — used by the + # `owner:me` magic alias which `owner:"me"` opts out of). + ('owner:jdoe', [], [_u('owner', 'jdoe')]), + ('owner:me', [], [_u('owner', 'me')]), + ('owner:"me"', [], [_q('owner', 'me')]), # Owner with email value (the parser doesn't validate the value shape) - ('owner:user@example.com', [], [('owner', 'user@example.com')]), + ('owner:user@example.com', [], [_u('owner', 'user@example.com')]), ], ids=[ 'empty', @@ -62,6 +77,8 @@ 'special-chars-in-quoted-value', 'quoted-operator-like-token-is-free-text', 'owner-username', + 'owner-me-unquoted', + 'owner-me-quoted', 'owner-email', ], ) diff --git a/dandiapi/api/views/serializers.py b/dandiapi/api/views/serializers.py index 45e85bf6a..44f1d3aad 100644 --- a/dandiapi/api/views/serializers.py +++ b/dandiapi/api/views/serializers.py @@ -313,8 +313,9 @@ class DandisetQueryParameterSerializer(serializers.Serializer): 'substring against the corresponding asset_metadata array); ' 'file_type (nwb, image, text, video — or any MIME prefix); ' 'owner (case-insensitive match against username, email, first ' - 'name, last name, or "first last"; "owner:me" resolves to the ' - 'requesting user). ' + 'name, last name, or "first last"; the magic value "owner:me" ' + 'resolves to the requesting user — quote it as "owner:\\"me\\"" ' + 'to match a literal user named Me instead). ' 'Invalid syntax returns HTTP 400 with the offending token; ' 'unknown operators get a "Did you mean?" suggestion.' ), diff --git a/web/src/components/DandisetSearchField.vue b/web/src/components/DandisetSearchField.vue index fc1bc8625..e136c5fb2 100644 --- a/web/src/components/DandisetSearchField.vue +++ b/web/src/components/DandisetSearchField.vue @@ -95,7 +95,7 @@ const operatorHelp = [ { example: 'technique:"patch clamp"', description: 'Has assets using a measurement technique' }, { example: 'standard:nwb', description: 'Has assets in a data standard' }, { example: 'file_type:nwb', description: 'Has assets of a file type (nwb, image, text, video)' }, - { example: 'owner:"Jane Doe"', description: 'Owned by a user (name, username, or email; or "owner:me")' }, + { example: 'owner:"Jane Doe"', description: 'Owned by a user (name, username, email; or owner:me)' }, ]; function updateSearch(search: string) { From a328da1d281195dbd02b5afe86f5eab226bad3e7 Mon Sep 17 00:00:00 2001 From: Ben Dichter Date: Mon, 11 May 2026 11:42:22 -0400 Subject: [PATCH 5/5] Drop owner:me magic alias (defer to a follow-up PR) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unquoted owner:me → current-user shortcut required threading a `quoted` flag through the parser and a `request_user` arg through the filter dispatch — non-trivial machinery to support one alias. Per #2822 review discussion, removing it from this PR keeps the owner operator focused on literal lookup-by-value (username / email / first / last / "first last") and avoids the design debate about the right escape mechanism for "I literally want a user named Me." The alias can come back in a focused follow-up PR if/when there's appetite for it. Concrete drops: - owner:me magic + 400-on-anonymous in `_apply_owner_filter` - `Operator.quoted` field on the parser dataclass - `quoted` and `request_user` parameters on `_apply_owner_filter` - `get_owned_dandisets` import (no longer used here) - `test_advanced_search_owner_me_magic_and_literal_escape` test - The two `owner-me-quoted` / `owner-me-unquoted` parser test cases - "owner:me" mentions in OpenAPI help text and the popover entry --- dandiapi/api/services/search/filters.py | 39 ++++------------------ dandiapi/api/services/search/parser.py | 12 ++----- dandiapi/api/tests/test_dandiset.py | 37 -------------------- dandiapi/api/tests/test_search_parser.py | 35 +++++-------------- dandiapi/api/views/serializers.py | 4 +-- web/src/components/DandisetSearchField.vue | 2 +- 6 files changed, 20 insertions(+), 109 deletions(-) diff --git a/dandiapi/api/services/search/filters.py b/dandiapi/api/services/search/filters.py index ec01a1c9f..d31a74e17 100644 --- a/dandiapi/api/services/search/filters.py +++ b/dandiapi/api/services/search/filters.py @@ -12,7 +12,6 @@ from dandiapi.api.models import Version from dandiapi.api.models.dandiset import DandisetUserObjectPermission -from dandiapi.api.services.permissions.dandiset import get_owned_dandisets from dandiapi.api.services.search.parser import SearchSyntaxError from dandiapi.search.models import AssetSearch @@ -109,40 +108,14 @@ def _apply_asset_filter(queryset, operator: str, value: str): raise ValueError(f'unknown asset operator: {operator}') # pragma: no cover -def _apply_owner_filter( - queryset: QuerySet[Dandiset], - value: str, - *, - quoted: bool, - request_user: User | AnonymousUser, -) -> QuerySet[Dandiset]: +def _apply_owner_filter(queryset: QuerySet[Dandiset], value: str) -> QuerySet[Dandiset]: """Filter dandisets to those owned by the given user identifier. - The unquoted token `owner:me` resolves to the requesting user. To search - for a literal user named "Me" instead, quote the value: `owner:"me"`. - The quoted form bypasses the magic alias and goes straight to the - case-insensitive lookup. - - Lookups (for any non-magic value) match case-insensitively against - `User.username`, `User.email`, `User.first_name`, `User.last_name`, or - `"first_name last_name"` (so the display name shown in the UI works). - Multiple users may match (common when only a first or last name is given); - we union dandisets owned by any of them. Unknown user → empty result (not - an error — a search for a nonexistent owner is a valid 0-hit query). - - Direct query against `DandisetUserObjectPermission` rather than guardian's - `get_objects_for_user` so we can union across multiple matched users in a - single query, and to bypass the superuser-sees-everything default. + `value` is matched case-insensitively against `User.username`, `User.email`, + `User.first_name`, `User.last_name`, or `"first_name last_name"` (so the + display name shown in the UI works). Multiple users may match; we union + dandisets owned by any of them. Unknown user → empty result. """ - if not quoted and value.lower() == 'me': - if request_user.is_anonymous: - raise SearchSyntaxError( - 'owner:me requires authentication. Sign in, or use owner:"me" ' - 'to search for a literal user named Me.' - ) - owner_pks = get_owned_dandisets(request_user, include_superusers=False).values('pk') - return queryset.filter(pk__in=owner_pks) - matched_user_pks = ( User.objects.annotate(_full_name=Concat('first_name', Value(' '), 'last_name')) .filter( @@ -232,7 +205,7 @@ def apply_search_filters( asset_qs = AssetSearch.objects.visible_to(user) asset_qs = _apply_asset_filter(asset_qs, key, value) elif key in _OWNER_OPS: - queryset = _apply_owner_filter(queryset, value, quoted=op.quoted, request_user=user) + queryset = _apply_owner_filter(queryset, value) if asset_qs is not None: # NOTE perf: jsonb_path_exists with a runtime-built jsonpath cannot diff --git a/dandiapi/api/services/search/parser.py b/dandiapi/api/services/search/parser.py index d20367284..c3b688222 100644 --- a/dandiapi/api/services/search/parser.py +++ b/dandiapi/api/services/search/parser.py @@ -63,16 +63,10 @@ class SearchSyntaxError(ValueError): @dataclass class Operator: - """One parsed `key:value` operator. - - `quoted` records whether the value came from a quoted form (`key:"value"`). - Most operators ignore this, but it lets `owner:` distinguish the magic - `owner:me` (current user) from `owner:"me"` (literal user named "Me"). - """ + """One parsed `key:value` operator.""" key: str value: str - quoted: bool @dataclass @@ -114,7 +108,7 @@ def parse_search(query: str) -> ParsedSearch: for match in _TOKEN_RE.finditer(query): if (key := match.group('op_key')) is not None: _validate_operator_key(key) - parsed.operators.append(Operator(key, match.group('op_qval'), quoted=True)) + parsed.operators.append(Operator(key, match.group('op_qval'))) elif (free := match.group('free_quoted')) is not None: parsed.free_text.append(free) else: @@ -122,7 +116,7 @@ def parse_search(query: str) -> ParsedSearch: if op_match := _BARE_OP_RE.match(bare): key = op_match.group(1) _validate_operator_key(key) - parsed.operators.append(Operator(key, op_match.group(2), quoted=False)) + parsed.operators.append(Operator(key, op_match.group(2))) else: parsed.free_text.append(bare) return parsed diff --git a/dandiapi/api/tests/test_dandiset.py b/dandiapi/api/tests/test_dandiset.py index 0ebb4b920..871c1b062 100644 --- a/dandiapi/api/tests/test_dandiset.py +++ b/dandiapi/api/tests/test_dandiset.py @@ -2152,43 +2152,6 @@ def test_advanced_search_owner_lookup_paths_and_combinations(api_client): } -@pytest.mark.ai_generated -@pytest.mark.django_db -def test_advanced_search_owner_me_magic_and_literal_escape(api_client): - """`owner:me` (unquoted) is the magic alias for "the current user". - - Anonymous → 400. To search for a real user named "Me" instead, quote - the value (`owner:"me"`) — the quoted form opts out of the magic and - falls back to the case-insensitive lookup against username / email / - first / last / full name. - """ - alice = UserFactory.create(username='alice') - me_user = UserFactory.create(username='me_actual_user', first_name='Me', last_name='Someoneyou') - alice_ds = DandisetFactory.create(owners=[alice]) - me_ds = DandisetFactory.create(owners=[me_user]) - DraftVersionFactory.create(dandiset=alice_ds) - DraftVersionFactory.create(dandiset=me_ds) - - # Anonymous + `owner:me` → 400 with explicit message - response = api_client.get( - '/api/dandisets/', - {'draft': 'true', 'empty': 'true', 'search': 'owner:me'}, - ) - assert response.status_code == 400 - assert 'requires authentication' in response.json()['search'] - - # Authenticated + unquoted `owner:me` → only alice's dandisets - # (does NOT match the literal user named "Me"). - api_client.force_authenticate(user=alice) - assert _search_ids(api_client, 'owner:me') == {alice_ds.identifier} - - # Authenticated + quoted `owner:"me"` → escapes the magic and matches - # the literal user "Me" by first_name (NOT alice). - assert _search_ids(api_client, 'owner:"me"') == {me_ds.identifier} - # Full display name lookup also works for that user. - assert _search_ids(api_client, 'owner:"Me Someoneyou"') == {me_ds.identifier} - - @pytest.mark.ai_generated @pytest.mark.django_db def test_advanced_search_owner_does_not_inflate_to_superuser_archive(api_client): diff --git a/dandiapi/api/tests/test_search_parser.py b/dandiapi/api/tests/test_search_parser.py index 4a72fb36c..2498a0b20 100644 --- a/dandiapi/api/tests/test_search_parser.py +++ b/dandiapi/api/tests/test_search_parser.py @@ -11,17 +11,6 @@ pytestmark = pytest.mark.ai_generated -# Convenience aliases so the parametrize table stays readable. -def _u(key: str, value: str) -> Operator: - """Unquoted operator (e.g. parsed from `key:value`).""" - return Operator(key, value, quoted=False) - - -def _q(key: str, value: str) -> Operator: - """Quoted operator (e.g. parsed from `key:"value"`).""" - return Operator(key, value, quoted=True) - - @pytest.mark.parametrize( ('query', 'expected_free_text', 'expected_operators'), [ @@ -34,36 +23,32 @@ def _q(key: str, value: str) -> Operator: ( 'species:mouse created_after:2024-01-01', [], - [_u('species', 'mouse'), _u('created_after', '2024-01-01')], + [Operator('species', 'mouse'), Operator('created_after', '2024-01-01')], ), # Mixed ( 'place cells species:mouse created_after:2024-01-01 ca1', ['place', 'cells', 'ca1'], - [_u('species', 'mouse'), _u('created_after', '2024-01-01')], + [Operator('species', 'mouse'), Operator('created_after', '2024-01-01')], ), # Quoted phrase as free text ('"place cells" hippocampus', ['place cells', 'hippocampus'], []), - # Quoted operator value (multi-word) — `quoted=True` - ('technique:"patch clamp"', [], [_q('technique', 'patch clamp')]), + # Quoted operator value (multi-word) + ('technique:"patch clamp"', [], [Operator('technique', 'patch clamp')]), # Repeated operator keeps every entry (AND'd downstream) ( 'species:mouse species:rat', [], - [_u('species', 'mouse'), _u('species', 'rat')], + [Operator('species', 'mouse'), Operator('species', 'rat')], ), # Special characters preserved inside quoted operator value - ('species:"C57BL/6"', [], [_q('species', 'C57BL/6')]), + ('species:"C57BL/6"', [], [Operator('species', 'C57BL/6')]), # Quoted token that *looks* like an operator is treated as free text — # documented escape hatch for searching for a literal colon. ('"foo:bar" hippocampus', ['foo:bar', 'hippocampus'], []), - # Owner operator (unquoted vs quoted distinguished — used by the - # `owner:me` magic alias which `owner:"me"` opts out of). - ('owner:jdoe', [], [_u('owner', 'jdoe')]), - ('owner:me', [], [_u('owner', 'me')]), - ('owner:"me"', [], [_q('owner', 'me')]), - # Owner with email value (the parser doesn't validate the value shape) - ('owner:user@example.com', [], [_u('owner', 'user@example.com')]), + # Owner operator + ('owner:jdoe', [], [Operator('owner', 'jdoe')]), + ('owner:user@example.com', [], [Operator('owner', 'user@example.com')]), ], ids=[ 'empty', @@ -77,8 +62,6 @@ def _q(key: str, value: str) -> Operator: 'special-chars-in-quoted-value', 'quoted-operator-like-token-is-free-text', 'owner-username', - 'owner-me-unquoted', - 'owner-me-quoted', 'owner-email', ], ) diff --git a/dandiapi/api/views/serializers.py b/dandiapi/api/views/serializers.py index 44f1d3aad..fb032a157 100644 --- a/dandiapi/api/views/serializers.py +++ b/dandiapi/api/views/serializers.py @@ -313,9 +313,7 @@ class DandisetQueryParameterSerializer(serializers.Serializer): 'substring against the corresponding asset_metadata array); ' 'file_type (nwb, image, text, video — or any MIME prefix); ' 'owner (case-insensitive match against username, email, first ' - 'name, last name, or "first last"; the magic value "owner:me" ' - 'resolves to the requesting user — quote it as "owner:\\"me\\"" ' - 'to match a literal user named Me instead). ' + 'name, last name, or "first last"). ' 'Invalid syntax returns HTTP 400 with the offending token; ' 'unknown operators get a "Did you mean?" suggestion.' ), diff --git a/web/src/components/DandisetSearchField.vue b/web/src/components/DandisetSearchField.vue index e136c5fb2..30f6738dc 100644 --- a/web/src/components/DandisetSearchField.vue +++ b/web/src/components/DandisetSearchField.vue @@ -95,7 +95,7 @@ const operatorHelp = [ { example: 'technique:"patch clamp"', description: 'Has assets using a measurement technique' }, { example: 'standard:nwb', description: 'Has assets in a data standard' }, { example: 'file_type:nwb', description: 'Has assets of a file type (nwb, image, text, video)' }, - { example: 'owner:"Jane Doe"', description: 'Owned by a user (name, username, email; or owner:me)' }, + { example: 'owner:"Jane Doe"', description: 'Owned by a user (name, username, or email)' }, ]; function updateSearch(search: string) {