Skip to content

Commit 21e05b8

Browse files
Added support for searching ecosystem and blog entries.
The blog results should have a property of whether it is included in the search results. * Added is_searchable. * Used Entry.get_absolute_url to encapsulate www host. * Extracted get_search_config helper function. Co-authored-by: Baptiste Mispelon <bmispelon@gmail.com>
1 parent 1bb7ff7 commit 21e05b8

File tree

9 files changed

+262
-19
lines changed

9 files changed

+262
-19
lines changed

blog/admin.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,15 @@
1111

1212
@admin.register(Entry)
1313
class EntryAdmin(admin.ModelAdmin):
14-
list_display = ("headline", "pub_date", "is_active", "is_published", "author")
15-
list_filter = ("is_active",)
14+
list_display = (
15+
"headline",
16+
"pub_date",
17+
"is_active",
18+
"is_published",
19+
"is_searchable",
20+
"author",
21+
)
22+
list_filter = ("is_active", "is_searchable")
1623
exclude = ("summary_html", "body_html")
1724
prepopulated_fields = {"slug": ("headline",)}
1825
raw_id_fields = ["social_media_card"]
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Generated by Django 5.2 on 2025-09-03 20:02
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("blog", "0005_entry_social_media_card"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="entry",
15+
name="is_searchable",
16+
field=models.BooleanField(
17+
default=False,
18+
help_text="Tick to make this entry appear in the Django documentation search.",
19+
),
20+
),
21+
]

blog/models.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ def published(self):
3737
def active(self):
3838
return self.filter(is_active=True)
3939

40+
def searchable(self):
41+
return self.filter(is_searchable=True)
42+
4043

4144
class ContentFormat(models.TextChoices):
4245
REST = "reST", "reStructuredText"
@@ -126,6 +129,12 @@ class Entry(models.Model):
126129
),
127130
default=False,
128131
)
132+
is_searchable = models.BooleanField(
133+
default=False,
134+
help_text=_(
135+
"Tick to make this entry appear in the Django documentation search."
136+
),
137+
)
129138
pub_date = models.DateTimeField(
130139
verbose_name=_("Publication date"),
131140
help_text=_(
@@ -168,7 +177,7 @@ def get_absolute_url(self):
168177
"day": self.pub_date.strftime("%d").lower(),
169178
"slug": self.slug,
170179
}
171-
return reverse("weblog:entry", kwargs=kwargs)
180+
return reverse("weblog:entry", kwargs=kwargs, host="www")
172181

173182
def is_published(self):
174183
"""

blog/tests.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,26 @@ def test_manager_published(self):
6868
transform=lambda entry: entry.headline,
6969
)
7070

71+
def test_manager_searchable(self):
72+
"""
73+
Make sure that the Entry manager's `searchable` method works
74+
"""
75+
Entry.objects.create(
76+
pub_date=self.yesterday,
77+
is_searchable=False,
78+
headline="not searchable",
79+
slug="a",
80+
)
81+
Entry.objects.create(
82+
pub_date=self.yesterday, is_searchable=True, headline="searchable", slug="b"
83+
)
84+
85+
self.assertQuerySetEqual(
86+
Entry.objects.searchable(),
87+
["searchable"],
88+
transform=lambda entry: entry.headline,
89+
)
90+
7191
def test_docutils_safe(self):
7292
"""
7393
Make sure docutils' file inclusion directives are disabled by default.

docs/models.py

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,18 +26,26 @@
2626
from django.utils.html import strip_tags
2727
from django_hosts.resolvers import reverse
2828

29+
from blog.models import Entry
2930
from releases.models import Release
3031

3132
from . import utils
3233
from .search import (
3334
DEFAULT_TEXT_SEARCH_CONFIG,
35+
SEARCHABLE_VIEWS,
3436
START_SEL,
3537
STOP_SEL,
3638
TSEARCH_CONFIG_LANGUAGES,
39+
DocumentationCategory,
3740
get_document_search_vector,
3841
)
3942

4043

44+
def get_search_config(lang):
45+
"""Determine the PostgreSQL search language"""
46+
return TSEARCH_CONFIG_LANGUAGES.get(lang[:2], DEFAULT_TEXT_SEARCH_CONFIG)
47+
48+
4149
class DocumentReleaseQuerySet(models.QuerySet):
4250
def current(self, lang="en"):
4351
current = self.get(is_default=True)
@@ -206,16 +214,84 @@ def sync_to_db(self, decoded_documents):
206214
path=document_path,
207215
title=html.unescape(strip_tags(document["title"])),
208216
metadata=document,
209-
config=TSEARCH_CONFIG_LANGUAGES.get(
210-
self.lang[:2], DEFAULT_TEXT_SEARCH_CONFIG
211-
),
217+
config=get_search_config(self.lang),
212218
)
213219
for document in self.documents.all():
214220
document.metadata["breadcrumbs"] = list(
215221
Document.objects.breadcrumbs(document).values("title", "path")
216222
)
217223
document.save(update_fields=("metadata",))
218224

225+
self._sync_blog_to_db()
226+
self._sync_views_to_db()
227+
228+
def _sync_blog_to_db(self):
229+
"""
230+
Sync the blog entries into search based on the release documents
231+
support end date.
232+
"""
233+
if self.lang != "en":
234+
return # The blog is only written in English currently
235+
236+
entries = Entry.objects.published().searchable()
237+
Document.objects.bulk_create(
238+
[
239+
Document(
240+
release=self,
241+
path=entry.get_absolute_url(),
242+
title=entry.headline,
243+
metadata={
244+
"body": entry.body_html,
245+
"breadcrumbs": [
246+
{
247+
"path": DocumentationCategory.WEBSITE,
248+
"title": "News",
249+
},
250+
],
251+
"parents": DocumentationCategory.WEBSITE,
252+
"slug": entry.slug,
253+
"title": entry.headline,
254+
"toc": "",
255+
},
256+
config=get_search_config(self.lang),
257+
)
258+
for entry in entries
259+
]
260+
)
261+
262+
def _sync_views_to_db(self):
263+
"""
264+
Sync the specific views into search based on the release documents
265+
support end date.
266+
"""
267+
if self.lang != "en":
268+
return # The searchable views are only written in English currently
269+
270+
Document.objects.bulk_create(
271+
[
272+
Document(
273+
release=self,
274+
path=searchable_view.www_absolute_url,
275+
title=searchable_view.page_title,
276+
metadata={
277+
"body": searchable_view.html,
278+
"breadcrumbs": [
279+
{
280+
"path": DocumentationCategory.WEBSITE,
281+
"title": "Website",
282+
},
283+
],
284+
"parents": DocumentationCategory.WEBSITE,
285+
"slug": searchable_view.url_name,
286+
"title": searchable_view.page_title,
287+
"toc": "",
288+
},
289+
config=get_search_config(self.lang),
290+
)
291+
for searchable_view in SEARCHABLE_VIEWS
292+
]
293+
)
294+
219295

220296
def _clean_document_path(path):
221297
# We have to be a bit careful to reverse-engineer the correct
@@ -228,7 +304,9 @@ def _clean_document_path(path):
228304

229305

230306
def document_url(doc):
231-
if doc.path:
307+
if doc.metadata.get("parents") == DocumentationCategory.WEBSITE:
308+
return doc.path
309+
elif doc.path:
232310
kwargs = {
233311
"lang": doc.release.lang,
234312
"version": doc.release.version,

docs/search.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
from dataclasses import dataclass
2+
13
from django.contrib.postgres.search import SearchVector
24
from django.db.models import TextChoices
35
from django.db.models.fields.json import KeyTextTransform
6+
from django.template.loader import get_template
47
from django.utils.translation import gettext_lazy as _
8+
from django_hosts import reverse
59

610
# Imported from
711
# https://github.com/postgres/postgres/blob/REL_14_STABLE/src/bin/initdb/initdb.c#L659
@@ -67,10 +71,35 @@ class DocumentationCategory(TextChoices):
6771
TOPICS = "topics", _("Using Django")
6872
HOWTO = "howto", _("How-to guides")
6973
RELEASE_NOTES = "releases", _("Release notes")
74+
WEBSITE = "website", _("Django Website")
7075

7176
@classmethod
7277
def parse(cls, value, default=None):
7378
try:
7479
return cls(value)
7580
except ValueError:
7681
return None
82+
83+
84+
@dataclass
85+
class SearchableView:
86+
page_title: str
87+
url_name: str
88+
template: str
89+
90+
@property
91+
def html(self):
92+
return get_template(self.template).render()
93+
94+
@property
95+
def www_absolute_url(self):
96+
return reverse(self.url_name, host="www")
97+
98+
99+
SEARCHABLE_VIEWS = [
100+
SearchableView(
101+
page_title="Django's Ecosystem",
102+
url_name="community-ecosystem",
103+
template="aggregator/ecosystem.html",
104+
),
105+
]

docs/templates/docs/search_results.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,11 @@ <h2>{% translate "No search query given" %}</h2>
4343
{% for result in page.object_list %}
4444
<dt>
4545
<h2 class="result-title">
46-
<a href="{% url 'document-detail' lang=result.release.lang version=result.release.version url=result.path host 'docs' %}{% if not start_sel in result.headline %}{{ result.highlight|fragment }}{% endif %}">{{ result.headline|safe }}</a>
46+
<a href="{{ result.get_absolute_url }}{% if not start_sel in result.headline %}{{ result.highlight|fragment }}{% endif %}">{{ result.headline|safe }}</a>
4747
</h2>
4848
<span class="meta breadcrumbs">
4949
{% for breadcrumb in result.breadcrumbs %}
50-
<a href="{% url 'document-detail' lang=result.release.lang version=result.release.version url=breadcrumb.path host 'docs' %}">{{ breadcrumb.title }}</a>{% if not forloop.last %} <span class="arrow">»</span>{% endif %}
50+
<a href="{{ result.get_absolute_url }}">{{ breadcrumb.title }}</a>{% if not forloop.last %} <span class="arrow">»</span>{% endif %}
5151
{% endfor %}
5252
</span>
5353
</dt>
@@ -60,7 +60,7 @@ <h2 class="result-title">
6060
<ul class="code-links">
6161
{% for name, value in result_code_links.items %}
6262
<li>
63-
<a href="{% url 'document-detail' lang=result.release.lang version=result.release.version url=result.path host 'docs' %}#{{ value.full_path }}">
63+
<a href="{{ result.get_absolute_url }}#{{ value.full_path }}">
6464
<div>
6565
<code>{{ name }}</code>
6666
{% if value.module_path %}<div class="meta">{{ value.module_path }}</div>{% endif %}

0 commit comments

Comments
 (0)