From d8b763592f3ee73b13a4b46257b2a8d25412abe1 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Wed, 5 Nov 2025 21:18:26 +1100 Subject: [PATCH] Update search function --- data/db.sqlite3 | Bin 479232 -> 479232 bytes links/apps.py | 10 + links/management/__init__.py | 0 links/management/commands/__init__.py | 0 .../commands/rebuild_search_index.py | 14 + .../migrations/0035_remove_webpage_content.py | 58 ++++ links/search_backend.py | 266 ++++++++++++++++++ links/search_urls.py | 6 +- links/search_views.py | 134 +++++---- links/signals.py | 94 +++++++ links/templates/links/search.html | 16 +- links/urls.py | 2 - pyproject.toml | 1 + uv.lock | 17 ++ 14 files changed, 551 insertions(+), 67 deletions(-) create mode 100644 links/apps.py create mode 100644 links/management/__init__.py create mode 100644 links/management/commands/__init__.py create mode 100644 links/management/commands/rebuild_search_index.py create mode 100644 links/migrations/0035_remove_webpage_content.py create mode 100644 links/search_backend.py create mode 100644 links/signals.py diff --git a/data/db.sqlite3 b/data/db.sqlite3 index 852b63b17e56e22f0c8c73700a5a50e19e453c25..3b3aae41698b02e138af9ddc6ac563a4e10fa64c 100644 GIT binary patch delta 11589 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 f@z$-?Oyk;j6VyuE+le%#tj9 delta 6256 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 1 + } + + +# Global search backend instance +search_backend = SearchBackend() diff --git a/links/search_urls.py b/links/search_urls.py index a3ce020..314ec95 100644 --- a/links/search_urls.py +++ b/links/search_urls.py @@ -1,6 +1,8 @@ from django.urls import path -from . import views +from .search_views import SearchView, search, search_aliases urlpatterns = [ - path('', views.search_aliases, name='search_aliases'), + path('', SearchView.as_view(), name='search'), + path('api/', search, name='search_api'), + path('aliases/', search_aliases, name='search_aliases'), ] diff --git a/links/search_views.py b/links/search_views.py index 6f190c7..2b666f6 100644 --- a/links/search_views.py +++ b/links/search_views.py @@ -6,6 +6,7 @@ from django.db.models import Q, Value, CharField from django.db import models from django.utils.text import slugify from .models import Link, Page, Post +from .search_backend import search_backend logger = logging.getLogger(__name__) @@ -17,77 +18,86 @@ class SearchView(ListView): def get_queryset(self): query = self.request.GET.get('q', '').strip() type_filter = self.request.GET.get('type', '') - sort_order = self.request.GET.get('sort', 'newest') + page_num = self.request.GET.get('page', 1) + + try: + page_num = int(page_num) + except (ValueError, TypeError): + page_num = 1 if query: - results = [] - - # Apply type filter - if type_filter: - if type_filter == 'link': - results = self._get_link_results(query) - elif type_filter == 'page': - results = self._get_page_results(query) - elif type_filter == 'post': - results = self._get_post_results(query) - else: - # Get all results - results = self._get_all_results(query) - - # Apply sorting - results = self._sort_results(results, sort_order) - - return results + # Use Whoosh search backend for full-text search + search_results = search_backend.search( + query_string=query, + model_type=type_filter if type_filter else None, + page=page_num, + per_page=self.paginate_by + ) + + # Enrich results with actual model data + enriched_results = [] + for result in search_results['results']: + enriched_result = self._enrich_result(result) + if enriched_result: + enriched_results.append(enriched_result) + + # Store search metadata for pagination + self.search_total = search_results['total'] + self.search_has_next = search_results['has_next'] + self.search_has_prev = search_results['has_prev'] + + return enriched_results return [] - - def _get_link_results(self, query): - return Link.objects.filter( - Q(alias__icontains=query) | - Q(original_url__icontains=query) | - Q(description__icontains=query) - ).values('id', 'alias', 'original_url', 'click_count', 'created_at').annotate( - model_type=Value('link', output_field=CharField()) - ) - - def _get_page_results(self, query): - return Page.objects.filter( - Q(url__icontains=query) | - Q(title__icontains=query) | - Q(summary__icontains=query) | - Q(content__icontains=query) - ).values('id', 'url', 'title', 'summary', 'created_at').annotate( - model_type=Value('page', output_field=CharField()) - ) - - def _get_post_results(self, query): - return Post.objects.filter( - Q(title__icontains=query) | - Q(content__icontains=query) - ).values('id', 'title', 'created_at').annotate( - model_type=Value('post', output_field=CharField()) - ) - - def _get_all_results(self, query): - links = self._get_link_results(query) - pages = self._get_page_results(query) - posts = self._get_post_results(query) - return list(chain(links, pages, posts)) - - def _sort_results(self, results, sort_order): - if sort_order == 'newest': - return sorted(results, key=lambda x: x.get('created_at', ''), reverse=True) - elif sort_order == 'oldest': - return sorted(results, key=lambda x: x.get('created_at', '')) - elif sort_order == 'type': - return sorted(results, key=lambda x: (x.get('model_type', ''), x.get('created_at', '')), reverse=True) - return results + + def _enrich_result(self, result): + """Fetch full model data for search result""" + model_type = result['model_type'] + model_id = result['model_id'] + + try: + if model_type == 'link': + link = Link.objects.get(id=model_id) + return { + 'id': link.id, + 'model_type': 'link', + 'alias': link.alias, + 'original_url': link.original_url, + 'description': link.description, + 'click_count': link.click_count, + 'created_at': link.created_at, + 'tags': list(link.tags.all()) + } + elif model_type == 'page': + page = Page.objects.get(id=model_id) + return { + 'id': page.id, + 'model_type': 'page', + 'title': page.title, + 'url': page.url, + 'summary': page.summary, + 'created_at': page.created_at, + 'tags': list(page.tags.all()) + } + elif model_type == 'post': + post = Post.objects.get(id=model_id) + return { + 'id': post.id, + 'model_type': 'post', + 'title': post.title, + 'summary': post.summary, + 'created_at': post.created_at, + 'tags': list(post.tags.all()) + } + except Exception as e: + logger.error(f"Error enriching result {model_type} {model_id}: {e}") + return None def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context.update({ 'query': self.request.GET.get('q', ''), 'selected_type': self.request.GET.get('type', ''), - 'sort_order': self.request.GET.get('sort', 'newest'), + 'total_results': getattr(self, 'search_total', 0), }) return context diff --git a/links/signals.py b/links/signals.py new file mode 100644 index 0000000..2a62cf1 --- /dev/null +++ b/links/signals.py @@ -0,0 +1,94 @@ +""" +Django signals for search index maintenance +""" +from django.db.models.signals import post_save, post_delete, m2m_changed +from django.dispatch import receiver +from .models import Link, Page, Post +from .search_backend import search_backend +import logging + +logger = logging.getLogger(__name__) + + +@receiver(post_save, sender=Link) +def update_link_index(sender, instance, created, **kwargs): + """Update search index when a link is saved""" + try: + search_backend.index_link(instance) + except Exception as e: + logger.error(f"Error indexing link {instance.id}: {e}") + + +@receiver(post_save, sender=Page) +def update_page_index(sender, instance, created, **kwargs): + """Update search index when a page is saved""" + try: + search_backend.index_page(instance) + except Exception as e: + logger.error(f"Error indexing page {instance.id}: {e}") + + +@receiver(post_save, sender=Post) +def update_post_index(sender, instance, created, **kwargs): + """Update search index when a post is saved""" + try: + search_backend.index_post(instance) + except Exception as e: + logger.error(f"Error indexing post {instance.id}: {e}") + + +@receiver(post_delete, sender=Link) +def remove_link_from_index(sender, instance, **kwargs): + """Remove link from search index when deleted""" + try: + search_backend.remove_from_index('link', instance.id) + except Exception as e: + logger.error(f"Error removing link {instance.id} from index: {e}") + + +@receiver(post_delete, sender=Page) +def remove_page_from_index(sender, instance, **kwargs): + """Remove page from search index when deleted""" + try: + search_backend.remove_from_index('page', instance.id) + except Exception as e: + logger.error(f"Error removing page {instance.id} from index: {e}") + + +@receiver(post_delete, sender=Post) +def remove_post_from_index(sender, instance, **kwargs): + """Remove post from search index when deleted""" + try: + search_backend.remove_from_index('post', instance.id) + except Exception as e: + logger.error(f"Error removing post {instance.id} from index: {e}") + + +@receiver(m2m_changed, sender=Link.tags.through) +def update_link_tags_index(sender, instance, action, **kwargs): + """Update link index when tags change""" + if action in ['post_add', 'post_remove', 'post_clear']: + try: + search_backend.index_link(instance) + except Exception as e: + logger.error(f"Error updating link {instance.id} tags in index: {e}") + + +@receiver(m2m_changed, sender=Page.tags.through) +def update_page_tags_index(sender, instance, action, **kwargs): + """Update page index when tags change""" + if action in ['post_add', 'post_remove', 'post_clear']: + try: + search_backend.index_page(instance) + except Exception as e: + logger.error(f"Error updating page {instance.id} tags in index: {e}") + + +@receiver(m2m_changed, sender=Post.tags.through) +def update_post_tags_index(sender, instance, action, **kwargs): + """Update post index when tags change""" + if action in ['post_add', 'post_remove', 'post_clear']: + try: + search_backend.index_post(instance) + except Exception as e: + logger.error(f"Error updating post {instance.id} tags in index: {e}") diff --git a/links/templates/links/search.html b/links/templates/links/search.html index ec5861b..9c746a8 100644 --- a/links/templates/links/search.html +++ b/links/templates/links/search.html @@ -79,6 +79,11 @@

{% trans "Search results for" %}: {{ query }}

+ {% if total_results %} +

+ {% trans "Found" %} {{ total_results }} {% trans "results" %} +

+ {% endif %} {% if results %} @@ -127,7 +132,7 @@ {{ result.title|default:"Untitled" }} - + {% trans "Page" %} @@ -138,6 +143,15 @@ {% if result.summary %}

{{ result.summary }}

{% endif %} + {% if result.tags %} +
+ {% for tag in result.tags %} + + {{ tag.name }} + + {% endfor %} +
+ {% endif %} {% endif %}
diff --git a/links/urls.py b/links/urls.py index 1b71e9d..48d2604 100644 --- a/links/urls.py +++ b/links/urls.py @@ -16,8 +16,6 @@ urlpatterns = [ path('delete-selected/', views.delete_selected, name='delete_selected'), path('detail//', views.LinkDetailView.as_view(), name='link_detail'), path('ui/search/', search_views.SearchView.as_view(), name='search'), - path('search/', search_views.search, name='search-links'), - path('search-aliases/', search_views.search_aliases, name='search-aliases'), path('link//', views.LinkDetailView.as_view(), name='link_detail'), path('link//edit/', views.LinkUpdateView.as_view(), name='link_update'), path('ui/tools/', views.ToolsView.as_view(), name='tools'), diff --git a/pyproject.toml b/pyproject.toml index 7975a7c..e301fa2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ "boto3>=1.35.0", "python-magic>=0.4.27", "pillow~=11.2.1", + "whoosh==2.7.4", ] [build-system] diff --git a/uv.lock b/uv.lock index 3da1be1..5dd95af 100644 --- a/uv.lock +++ b/uv.lock @@ -244,6 +244,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, { url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" }, + { url = "https://files.pythonhosted.org/packages/27/45/80935968b53cfd3f33cf99ea5f08227f2646e044568c9b1555b58ffd61c2/greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0", size = 1564846, upload-time = "2025-11-04T12:42:15.191Z" }, + { url = "https://files.pythonhosted.org/packages/69/02/b7c30e5e04752cb4db6202a3858b149c0710e5453b71a3b2aec5d78a1aab/greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d", size = 1633814, upload-time = "2025-11-04T12:42:17.175Z" }, { url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" }, { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, @@ -253,6 +255,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" }, + { url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" }, { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" }, { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, @@ -260,6 +264,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" }, + { url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" }, { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, ] @@ -628,6 +634,7 @@ dependencies = [ { name = "requests" }, { name = "sqlparse" }, { name = "whitenoise" }, + { name = "whoosh" }, ] [package.optional-dependencies] @@ -664,6 +671,7 @@ requires-dist = [ { name = "requests", specifier = "==2.32.4" }, { name = "sqlparse", specifier = "==0.5.1" }, { name = "whitenoise", specifier = "==5.3.0" }, + { name = "whoosh", specifier = "==2.7.4" }, ] provides-extras = ["dev"] @@ -684,3 +692,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/78/29/84c808294f76d854e wheels = [ { url = "https://files.pythonhosted.org/packages/8d/1e/ec69984d05e570ec7c61d28cbce51b8f4623e4121ca57ac6ad76e4f5ffe8/whitenoise-5.3.0-py2.py3-none-any.whl", hash = "sha256:d963ef25639d1417e8a247be36e6aedd8c7c6f0a08adcb5a89146980a96b577c", size = 19822, upload-time = "2021-07-16T16:59:52.113Z" }, ] + +[[package]] +name = "whoosh" +version = "2.7.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/2b/6beed2107b148edc1321da0d489afc4617b9ed317ef7b72d4993cad9b684/Whoosh-2.7.4.tar.gz", hash = "sha256:7ca5633dbfa9e0e0fa400d3151a8a0c4bec53bd2ecedc0a67705b17565c31a83", size = 968741, upload-time = "2016-04-04T01:19:32.327Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/24d0f1f454a2c1eb689ca28d2f178db81e5024f42d82729a4ff6771155cf/Whoosh-2.7.4-py2.py3-none-any.whl", hash = "sha256:aa39c3c3426e3fd107dcb4bde64ca1e276a65a889d9085a6e4b54ba82420a852", size = 468790, upload-time = "2016-04-04T01:19:40.379Z" }, +]