Repository: saleor/saleor
Pull request: Add `search` to giftcard query
Issue: https://github.com/saleor/saleor/issues/13082
I want to merge this change, because I want to add full text search to `giftCards` query.

<!-- Please mention all relevant issue numbers. -->

# Impact

* [x] New migrations
* [x] New/Updated API fields or mutations
* [ ] Deprecated API fields or mutations
* [ ] Removed API types, fields, or mutations
* [ ] Documentation needs to be updated

# Pull Request Checklist

<!-- Please keep this section. It will make the maintainer's life easier. -->

* [ ] Privileged queries and mutations are either absent or guarded by proper permission checks
* [ ] Database queries are optimized and the number of queries is constant
* [ ] Database migrations are either absent or optimized for zero downtime
* [ ] The changes are covered by test cases


Problem statement:
Search query for giftcards
## Problem

Customer support needs to able to find gift cards by the name or email of customers. 

`usedBy` filter field doesn't help as it only accepts IDs of existing accounts, which doesn't cover anonymous users.

Another pain point is that admins want to use generic search that accepts any input instead of filters, the same way as product search works
```[tasklist]
### Tasks
```

Hints from issue discussion:
- `GiftCard` model changes:
  - Add `search_index_dirty = models.BooleanField(default=False, db_index=True)` field
  - Add `search_vector = SearchVectorField(blank=True, null=True)` field.
    - Fields to include in the search vector:
      - `GiftCard.code`
      - `GiftCard.used_by_email`
      - `GiftCard.created_by_email`
      - `GiftCard.used_by` - user's email, first name and last name
      - `GiftCard.createdBy` - user's email, first name and last name
    - See `prepare_product_search_vector_value` for a reference
- Update logic to mark `search_index_dirty=True` whenever parts of the search vector change.
- Add a Celery task that looks for dirty search indexes and updates the search vector value. The task should update a batch of gift cards in a single run and check if there are still gift cards to update and schedule itself in the queue to process the remaining ones in separate tasks.

Commit message: Config celery task.

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ac094f22768..3c8039d3766 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,7 @@ All notable, unreleased changes to this project will be documented in this file.
 - Deprecate `Order.trackingClientId` field - #13146 by @SzymJ
 - Fix error "Cannot return null for non-nullable field Webhook.name" - #12989 by @Smit-Parmar
 - Added `GiftCardFilterInput.createdByEmail` filter - #13132 by @Smit-Parmar
+- Add `search` to `giftCards` query - #13173 by @zedzior
 
 ### Saleor Apps
 
diff --git a/saleor/giftcard/migrations/0019_auto_20230626_1315.py b/saleor/giftcard/migrations/0019_auto_20230626_1315.py
new file mode 100644
index 00000000000..8dc79d090c6
--- /dev/null
+++ b/saleor/giftcard/migrations/0019_auto_20230626_1315.py
@@ -0,0 +1,26 @@
+# Generated by Django 3.2.18 on 2023-06-26 13:15
+
+import django.contrib.postgres.indexes
+import django.contrib.postgres.search
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("giftcard", "0018_metadata_index"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="giftcard",
+            name="search_index_dirty",
+            field=models.BooleanField(default=True),
+        ),
+        migrations.AddField(
+            model_name="giftcard",
+            name="search_vector",
+            field=django.contrib.postgres.search.SearchVectorField(
+                blank=True, null=True
+            ),
+        ),
+    ]
diff --git a/saleor/giftcard/migrations/0020_search_vector_index.py b/saleor/giftcard/migrations/0020_search_vector_index.py
new file mode 100644
index 00000000000..4f99263d0f0
--- /dev/null
+++ b/saleor/giftcard/migrations/0020_search_vector_index.py
@@ -0,0 +1,21 @@
+# Generated by Django 3.2.18 on 2023-06-26 13:28
+
+from django.contrib.postgres.operations import AddIndexConcurrently
+import django.contrib.postgres.indexes
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("giftcard", "0019_auto_20230626_1315"),
+    ]
+    atomic = False
+    operations = [
+        AddIndexConcurrently(
+            model_name="giftcard",
+            index=django.contrib.postgres.indexes.GinIndex(
+                fields=["search_vector"],
+                name="giftcard_tsearch",
+            ),
+        ),
+    ]
diff --git a/saleor/giftcard/models.py b/saleor/giftcard/models.py
index e55770f4999..2ec15f35756 100644
--- a/saleor/giftcard/models.py
+++ b/saleor/giftcard/models.py
@@ -2,6 +2,7 @@
 
 from django.conf import settings
 from django.contrib.postgres.indexes import GinIndex
+from django.contrib.postgres.search import SearchVectorField
 from django.core.validators import MinLengthValidator
 from django.db import models
 from django.db.models import JSONField, Q
@@ -110,6 +111,8 @@ class GiftCard(ModelWithMetadata):
     current_balance = MoneyField(
         amount_field="current_balance_amount", currency_field="currency"
     )
+    search_vector = SearchVectorField(blank=True, null=True)
+    search_index_dirty = models.BooleanField(default=True)
 
     objects = GiftCardManager()
 
@@ -118,6 +121,8 @@ class Meta(ModelWithMetadata.Meta):
         permissions = (
             (GiftcardPermissions.MANAGE_GIFT_CARD.codename, "Manage gift cards."),
         )
+        indexes = [GinIndex(name="giftcard_tsearch", fields=["search_vector"])]
+        indexes.extend(ModelWithMetadata.Meta.indexes)
 
     @property
     def display_code(self):
diff --git a/saleor/giftcard/search.py b/saleor/giftcard/search.py
new file mode 100644
index 00000000000..a9c8e7fbf47
--- /dev/null
+++ b/saleor/giftcard/search.py
@@ -0,0 +1,56 @@
+from typing import List
+
+from django.contrib.postgres.search import SearchQuery
+from django.db.models import Value, prefetch_related_objects
+
+from ..core.postgres import FlatConcatSearchVector, NoValidationSearchVector
+from .models import GiftCard
+
+GIFTCARD_FIELDS_TO_PREFETCH = ["used_by", "created_by"]
+
+
+def _add_vector(vectors: List[NoValidationSearchVector], field):
+    if field:
+        vectors += [NoValidationSearchVector(Value(field), config="simple")]
+
+
+def prepare_gift_card_search_vector_value(
+    gift_card: GiftCard,
+) -> List[NoValidationSearchVector]:
+    search_vectors = [NoValidationSearchVector(Value(gift_card.code), config="simple")]
+    _add_vector(search_vectors, gift_card.used_by_email)
+    _add_vector(search_vectors, gift_card.created_by_email)
+    if gift_card.used_by:
+        _add_vector(search_vectors, gift_card.used_by.email)
+        _add_vector(search_vectors, gift_card.used_by.first_name)
+        _add_vector(search_vectors, gift_card.used_by.last_name)
+    if gift_card.created_by:
+        _add_vector(search_vectors, gift_card.created_by.email)
+        _add_vector(search_vectors, gift_card.created_by.first_name)
+        _add_vector(search_vectors, gift_card.created_by.last_name)
+
+    return search_vectors
+
+
+def mark_gift_cards_search_index_as_dirty(gift_cards: List[GiftCard]):
+    for gift_card in gift_cards:
+        gift_card.search_index_dirty = True
+    GiftCard.objects.bulk_update(gift_cards, ["search_index_dirty"])
+
+
+def update_gift_cards_search_vector(gift_cards: List[GiftCard]):
+    prefetch_related_objects(gift_cards, *GIFTCARD_FIELDS_TO_PREFETCH)
+    for gift_card in gift_cards:
+        gift_card.search_index_dirty = False
+        gift_card.search_vector = FlatConcatSearchVector(
+            *prepare_gift_card_search_vector_value(gift_card)
+        )
+
+    GiftCard.objects.bulk_update(gift_cards, ["search_vector", "search_index_dirty"])
+
+
+def search_gift_cards(qs, value):
+    if value:
+        query = SearchQuery(value, search_type="websearch", config="simple")
+        qs = qs.filter(search_vector=query)
+    return qs
diff --git a/saleor/giftcard/tasks.py b/saleor/giftcard/tasks.py
index 7356b519aa8..f1483793311 100644
--- a/saleor/giftcard/tasks.py
+++ b/saleor/giftcard/tasks.py
@@ -1,11 +1,14 @@
 from celery.utils.log import get_task_logger
+from django.conf import settings
 from django.utils import timezone
 
 from ..celeryconf import app
 from .events import gift_cards_deactivated_event
 from .models import GiftCard
+from .search import update_gift_cards_search_vector
 
 task_logger = get_task_logger(__name__)
+GIFT_CARD_BATCH_SIZE = 300
 
 
 @app.task
@@ -18,3 +21,16 @@ def deactivate_expired_cards_task():
     count = gift_cards.update(is_active=False)
     gift_cards_deactivated_event(gift_card_ids, user=None, app=None)
     task_logger.debug("Deactivate %s gift cards", count)
+
+
+@app.task(
+    queue=settings.UPDATE_SEARCH_VECTOR_INDEX_QUEUE_NAME,
+    expires=settings.BEAT_UPDATE_SEARCH_EXPIRE_AFTER_SEC,
+)
+def update_gift_cards_search_vector_task():
+    gift_cards = GiftCard.objects.filter(search_index_dirty=True)
+    if not gift_cards:
+        return
+    gift_cards_batch = list(gift_cards[:GIFT_CARD_BATCH_SIZE])
+    update_gift_cards_search_vector(gift_cards_batch)
+    update_gift_cards_search_vector_task.delay()
diff --git a/saleor/graphql/giftcard/schema.py b/saleor/graphql/giftcard/schema.py
index af42454459d..f072967ebf1 100644
--- a/saleor/graphql/giftcard/schema.py
+++ b/saleor/graphql/giftcard/schema.py
@@ -2,10 +2,11 @@
 from graphql.error import GraphQLError
 
 from ...giftcard import models
+from ...giftcard.search import search_gift_cards
 from ...permission.enums import GiftcardPermissions
 from ..core import ResolveInfo
 from ..core.connection import create_connection_slice, filter_connection_queryset
-from ..core.descriptions import ADDED_IN_31
+from ..core.descriptions import ADDED_IN_31, ADDED_IN_315, PREVIEW_FEATURE
 from ..core.doc_category import DOC_CATEGORY_GIFT_CARDS
 from ..core.fields import FilterConnectionField, PermissionsField
 from ..core.types import NonNullList
@@ -49,6 +50,11 @@ class GiftCardQueries(graphene.ObjectType):
         filter=GiftCardFilterInput(
             description=("Filtering options for gift cards." + ADDED_IN_31)
         ),
+        search=graphene.String(
+            description="Look up gift cards by multiple fields."
+            + ADDED_IN_315
+            + PREVIEW_FEATURE
+        ),
         description="List of gift cards.",
         permissions=[
             GiftcardPermissions.MANAGE_GIFT_CARD,
@@ -83,7 +89,7 @@ def resolve_gift_card(_root, _info: ResolveInfo, /, *, id: str):
 
     @staticmethod
     def resolve_gift_cards(
-        _root, info: ResolveInfo, /, *, sort_by=None, filter=None, **kwargs
+        _root, info: ResolveInfo, /, *, sort_by=None, filter=None, search=None, **kwargs
     ):
         sorting_by_balance = sort_by and "current_balance_amount" in sort_by.get(
             "field", []
@@ -92,6 +98,8 @@ def resolve_gift_cards(
         if sorting_by_balance and not filtering_by_currency:
             raise GraphQLError("Sorting by balance requires filtering by currency.")
         qs = resolve_gift_cards()
+        if search:
+            qs = search_gift_cards(qs, search)
         qs = filter_connection_queryset(
             qs, {"sort_by": sort_by, "filter": filter, **kwargs}
         )
diff --git a/saleor/graphql/giftcard/tests/queries/test_gift_card_search.py b/saleor/graphql/giftcard/tests/queries/test_gift_card_search.py
new file mode 100644
index 00000000000..3ff74f4a699
--- /dev/null
+++ b/saleor/graphql/giftcard/tests/queries/test_gift_card_search.py
@@ -0,0 +1,55 @@
+import pytest
+
+from saleor.giftcard.search import update_gift_cards_search_vector
+
+from ....tests.utils import get_graphql_content
+
+QUERY_GIFT_CARDS = """
+    query giftCards(
+        $sortBy: GiftCardSortingInput,
+        $filter: GiftCardFilterInput,
+        $search: String,
+    ){
+        giftCards(first: 10, filter: $filter, sortBy: $sortBy, search: $search) {
+            edges {
+                node {
+                    id
+                    code
+                }
+            }
+            totalCount
+        }
+    }
+"""
+
+
+@pytest.mark.parametrize(
+    "search,indexes",
+    [("expiry", [0, 1]), ("staff_test@example.com", [2]), ("banana", [])],
+)
+def test_query_gift_cards_with_search(
+    search,
+    indexes,
+    staff_api_client,
+    gift_card,
+    gift_card_expiry_date,
+    gift_card_used,
+    permission_manage_gift_card,
+):
+    # given
+    gift_card_list = [gift_card, gift_card_expiry_date, gift_card_used]
+    update_gift_cards_search_vector(gift_card_list)
+    variables = {"search": search}
+
+    # when
+    response = staff_api_client.post_graphql(
+        QUERY_GIFT_CARDS, variables, permissions=[permission_manage_gift_card]
+    )
+
+    # then
+    content = get_graphql_content(response)
+    data = content["data"]["giftCards"]["edges"]
+    assert len(data) == len(indexes)
+    assert {card["node"]["code"] for card in data} == {
+        gift_card_list[index].code for index in indexes
+    }
diff --git a/saleor/graphql/schema.graphql b/saleor/graphql/schema.graphql
index 1e59f81324b..d84ce16a99f 100644
--- a/saleor/graphql/schema.graphql
+++ b/saleor/graphql/schema.graphql
@@ -973,6 +973,15 @@ type Query {
     """
     filter: GiftCardFilterInput
 
+    """
+    Look up gift cards by multiple fields.
+    
+    Added in Saleor 3.15.
+    
+    Note: this API is currently in Feature Preview and can be subject to changes at later point.
+    """
+    search: String
+
     """Return the elements in the list that come before the specified cursor."""
     before: String
 
diff --git a/saleor/order/utils.py b/saleor/order/utils.py
index 726e28d337d..d55db031b2b 100644
--- a/saleor/order/utils.py
+++ b/saleor/order/utils.py
@@ -23,6 +23,7 @@
 )
 from ..giftcard import events as gift_card_events
 from ..giftcard.models import GiftCard
+from ..giftcard.search import mark_gift_cards_search_index_as_dirty
 from ..payment.model_helpers import get_total_authorized
 from ..product.utils.digital_products import get_default_digital_content_settings
 from ..shipping.interface import ShippingMethodData
@@ -454,6 +455,7 @@ def set_gift_card_user(
         else User.objects.filter(email=used_by_email).first()
     )
     gift_card.used_by_email = used_by_email
+    mark_gift_cards_search_index_as_dirty([gift_card])
 
 
 def _update_allocations_for_line(
diff --git a/saleor/settings.py b/saleor/settings.py
index e54f36486ea..8a413805254 100644
--- a/saleor/settings.py
+++ b/saleor/settings.py
@@ -598,6 +598,11 @@ def get_host():
         "schedule": timedelta(seconds=20),
         "options": {"expires": BEAT_UPDATE_SEARCH_EXPIRE_AFTER_SEC},
     },
+    "update-gift-cards-search-vectors": {
+        "task": "saleor.giftcard.tasks.update_gift_cards_search_vector_task",
+        "schedule": timedelta(seconds=20),
+        "options": {"expires": BEAT_UPDATE_SEARCH_EXPIRE_AFTER_SEC},
+    },
     "expire-orders": {
         "task": "saleor.order.tasks.expire_orders_task",
         "schedule": BEAT_EXPIRE_ORDERS_AFTER_TIMEDELTA,


Code review comments:
diff_hunk: @@ -49,6 +50,11 @@ class GiftCardQueries(graphene.ObjectType):
         filter=GiftCardFilterInput(
             description=("Filtering options for gift cards." + ADDED_IN_31)
         ),
+        search=graphene.String(
+            description="Look up gift cards by multiple fields."
path: saleor/graphql/giftcard/schema.py
text: @user1:
Let's make it more explicit - "Search gift cards by email and name of user who created or used the gift card" - maybe something like that.