Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions blog/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,15 @@

@admin.register(Entry)
class EntryAdmin(admin.ModelAdmin):
list_display = ("headline", "pub_date", "is_active", "is_published", "author")
list_filter = ("is_active",)
list_display = (
"headline",
"pub_date",
"is_active",
"is_published",
"is_searchable",
"author",
)
list_filter = ("is_active", "is_searchable")
exclude = ("summary_html", "body_html")
prepopulated_fields = {"slug": ("headline",)}
raw_id_fields = ["social_media_card"]
Expand Down
21 changes: 21 additions & 0 deletions blog/migrations/0006_entry_is_searchable.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Generated by Django 5.2 on 2025-09-03 20:02

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("blog", "0005_entry_social_media_card"),
]

operations = [
migrations.AddField(
model_name="entry",
name="is_searchable",
field=models.BooleanField(
default=False,
help_text="Tick to make this entry appear in the Django documentation search.",
),
),
]
11 changes: 10 additions & 1 deletion blog/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ def published(self):
def active(self):
return self.filter(is_active=True)

def searchable(self):
return self.filter(is_searchable=True)


class ContentFormat(models.TextChoices):
REST = "reST", "reStructuredText"
Expand Down Expand Up @@ -126,6 +129,12 @@ class Entry(models.Model):
),
default=False,
)
is_searchable = models.BooleanField(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you have examples of blog entries you're expecting to be included and excluded? To me, I feel like everything should be searchable

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No I haven't, but since this is searching the docs, not searching the site, I would imagine blog posts such as announcing board elections would not be searchable.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would be tempted to default to True so that once this is launched, everything is included and we exclude if necessary following feedback

My motivation is I'm very tempted to make the searchbar, that's only available on docs, available everywhere and have us slowly add more website pages as searchable/indexed

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since I don't have admin access and can't manage this myself, I've written the PR to be very conservative. If you're volunteering to handle that, I'm happy to make those changes 😁🚀

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since I don't have admin access and can't manage this myself [...]

Well, you have access now 😁 (I've put you in the "blog authors" group so you should be able to see all the relevant models)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🫠

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose the most conservative is not to have the field and it's all searchable. I want to test but I feel like if you searched for someone's name or about Fellows or DjangoCons, you'd want to see blog results but not that many blog results would show up about specific features of Django unless it's a security release description 🤔

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sarahboyce I think we may have differing opinions on what we're trying to make searchable. To me the following blog posts should not be included in the search results:

  • 2026 DSF Board Nominations
  • 2025 Malcolm Tredinnick Memorial Prize awarded to Tim Schilling
  • Django security releases issued: 5.2.7, 5.1.13, and 4.2.25
    • This exists in the docs already
  • Sarah Boyce - Maternity leave announcement
  • Django 6.0 alpha 1 released
    • This exists in the docs already
  • Nominate a Djangonaut for the 2025 Malcolm Tredinnick Memorial Prize
  • Last call for DjangoCon US 2025 tickets!
    • Maybe if we could limit when it gets included? I don't want results about tickets being sold for a conference that's already happened.

Posts that should be searchable:

  • Getting Started With Open Source Through Community Events
  • Keyboard shortcuts in Django via GSoC 2025
  • Watch the DjangoCon Europe 2025 talks

Posts that I'm unsure about:

  • DSF at EuroPython 2025: Celebrating 20 years of Django
    • This feels really similar to the "tickets being sold" type post from above
  • DSF member of the month - [Name]
    • I can be easily convinced to include these. As of right now, my personal goal is to make the framework material more searchable, not necessarily the community.
  • Djangonaut Space is looking for contributors to be mentors
    • It's timeboxed and not always relevant

default=False,
help_text=_(
"Tick to make this entry appear in the Django documentation search."
),
)
pub_date = models.DateTimeField(
verbose_name=_("Publication date"),
help_text=_(
Expand Down Expand Up @@ -168,7 +177,7 @@ def get_absolute_url(self):
"day": self.pub_date.strftime("%d").lower(),
"slug": self.slug,
}
return reverse("weblog:entry", kwargs=kwargs)
return reverse("weblog:entry", kwargs=kwargs, host="www")

def is_published(self):
"""
Expand Down
20 changes: 20 additions & 0 deletions blog/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,26 @@ def test_manager_published(self):
transform=lambda entry: entry.headline,
)

def test_manager_searchable(self):
"""
Make sure that the Entry manager's `searchable` method works
"""
Entry.objects.create(
pub_date=self.yesterday,
is_searchable=False,
headline="not searchable",
slug="a",
)
Entry.objects.create(
pub_date=self.yesterday, is_searchable=True, headline="searchable", slug="b"
)

self.assertQuerySetEqual(
Entry.objects.searchable(),
["searchable"],
transform=lambda entry: entry.headline,
)

def test_docutils_safe(self):
"""
Make sure docutils' file inclusion directives are disabled by default.
Expand Down
86 changes: 82 additions & 4 deletions docs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,26 @@
from django.utils.html import strip_tags
from django_hosts.resolvers import reverse

from blog.models import Entry
from releases.models import Release

from . import utils
from .search import (
DEFAULT_TEXT_SEARCH_CONFIG,
SEARCHABLE_VIEWS,
START_SEL,
STOP_SEL,
TSEARCH_CONFIG_LANGUAGES,
DocumentationCategory,
get_document_search_vector,
)


def get_search_config(lang):
"""Determine the PostgreSQL search language"""
return TSEARCH_CONFIG_LANGUAGES.get(lang[:2], DEFAULT_TEXT_SEARCH_CONFIG)


class DocumentReleaseQuerySet(models.QuerySet):
def current(self, lang="en"):
current = self.get(is_default=True)
Expand Down Expand Up @@ -206,16 +214,84 @@ def sync_to_db(self, decoded_documents):
path=document_path,
title=html.unescape(strip_tags(document["title"])),
metadata=document,
config=TSEARCH_CONFIG_LANGUAGES.get(
self.lang[:2], DEFAULT_TEXT_SEARCH_CONFIG
),
config=get_search_config(self.lang),
)
for document in self.documents.all():
document.metadata["breadcrumbs"] = list(
Document.objects.breadcrumbs(document).values("title", "path")
)
document.save(update_fields=("metadata",))

self._sync_blog_to_db()
self._sync_views_to_db()

def _sync_blog_to_db(self):
"""
Sync the blog entries into search based on the release documents
support end date.
"""
if self.lang != "en":
return # The blog is only written in English currently

entries = Entry.objects.published().searchable()
Document.objects.bulk_create(
[
Document(
release=self,
path=entry.get_absolute_url(),
title=entry.headline,
metadata={
"body": entry.body_html,
"breadcrumbs": [
{
"path": DocumentationCategory.WEBSITE,
"title": "News",
},
],
"parents": DocumentationCategory.WEBSITE,
"slug": entry.slug,
"title": entry.headline,
"toc": "",
},
config=get_search_config(self.lang),
)
for entry in entries
]
)

def _sync_views_to_db(self):
"""
Sync the specific views into search based on the release documents
support end date.
"""
if self.lang != "en":
return # The searchable views are only written in English currently

Document.objects.bulk_create(
[
Document(
release=self,
path=searchable_view.www_absolute_url,
title=searchable_view.page_title,
metadata={
"body": searchable_view.html,
"breadcrumbs": [
{
"path": DocumentationCategory.WEBSITE,
"title": "Website",
},
],
"parents": DocumentationCategory.WEBSITE,
"slug": searchable_view.url_name,
"title": searchable_view.page_title,
"toc": "",
},
config=get_search_config(self.lang),
)
for searchable_view in SEARCHABLE_VIEWS
]
)


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


def document_url(doc):
if doc.path:
if doc.metadata.get("parents") == DocumentationCategory.WEBSITE:
return doc.path
elif doc.path:
kwargs = {
"lang": doc.release.lang,
"version": doc.release.version,
Expand Down
29 changes: 29 additions & 0 deletions docs/search.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
from dataclasses import dataclass

from django.contrib.postgres.search import SearchVector
from django.db.models import TextChoices
from django.db.models.fields.json import KeyTextTransform
from django.template.loader import get_template
from django.utils.translation import gettext_lazy as _
from django_hosts import reverse

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

@classmethod
def parse(cls, value, default=None):
try:
return cls(value)
except ValueError:
return None


@dataclass
class SearchableView:
page_title: str
url_name: str
template: str

@property
def html(self):
return get_template(self.template).render()

@property
def www_absolute_url(self):
return reverse(self.url_name, host="www")


SEARCHABLE_VIEWS = [
SearchableView(
page_title="Django's Ecosystem",
url_name="community-ecosystem",
template="aggregator/ecosystem.html",
),
]
6 changes: 3 additions & 3 deletions docs/templates/docs/search_results.html
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,11 @@ <h2>{% translate "No search query given" %}</h2>
{% for result in page.object_list %}
<dt>
<h2 class="result-title">
<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>
<a href="{{ result.get_absolute_url }}{% if not start_sel in result.headline %}{{ result.highlight|fragment }}{% endif %}">{{ result.headline|safe }}</a>
</h2>
<span class="meta breadcrumbs">
{% for breadcrumb in result.breadcrumbs %}
<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 %}
<a href="{{ result.get_absolute_url }}">{{ breadcrumb.title }}</a>{% if not forloop.last %} <span class="arrow">»</span>{% endif %}
{% endfor %}
</span>
</dt>
Expand All @@ -60,7 +60,7 @@ <h2 class="result-title">
<ul class="code-links">
{% for name, value in result_code_links.items %}
<li>
<a href="{% url 'document-detail' lang=result.release.lang version=result.release.version url=result.path host 'docs' %}#{{ value.full_path }}">
<a href="{{ result.get_absolute_url }}#{{ value.full_path }}">
<div>
<code>{{ name }}</code>
{% if value.module_path %}<div class="meta">{{ value.module_path }}</div>{% endif %}
Expand Down
Loading