diff --git a/blog/admin.py b/blog/admin.py index 22e1093d16..4af8550427 100644 --- a/blog/admin.py +++ b/blog/admin.py @@ -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"] diff --git a/blog/migrations/0006_entry_is_searchable.py b/blog/migrations/0006_entry_is_searchable.py new file mode 100644 index 0000000000..72f9bab84d --- /dev/null +++ b/blog/migrations/0006_entry_is_searchable.py @@ -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.", + ), + ), + ] diff --git a/blog/models.py b/blog/models.py index 988c607eb4..030264f366 100644 --- a/blog/models.py +++ b/blog/models.py @@ -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" @@ -126,6 +129,12 @@ class Entry(models.Model): ), default=False, ) + is_searchable = models.BooleanField( + 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=_( @@ -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): """ diff --git a/blog/tests.py b/blog/tests.py index b5c1b1802b..0253f8d407 100644 --- a/blog/tests.py +++ b/blog/tests.py @@ -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. diff --git a/djangoproject/settings/common.py b/djangoproject/settings/common.py index 82819c3cc0..a607f68cf9 100644 --- a/djangoproject/settings/common.py +++ b/djangoproject/settings/common.py @@ -313,3 +313,5 @@ DEFAULT_AUTO_FIELD = "django.db.models.AutoField" FORMS_URLFIELD_ASSUME_HTTPS = True + +DEFAULT_LANGUAGE_CODE = "en" diff --git a/docs/models.py b/docs/models.py index 17c2cca01a..c3ca00ccba 100644 --- a/docs/models.py +++ b/docs/models.py @@ -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) @@ -206,9 +214,7 @@ 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( @@ -216,6 +222,76 @@ def sync_to_db(self, decoded_documents): ) 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 @@ -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, diff --git a/docs/search.py b/docs/search.py index 3a99978d58..0b7eaef1d3 100644 --- a/docs/search.py +++ b/docs/search.py @@ -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 @@ -67,6 +71,7 @@ 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): @@ -74,3 +79,27 @@ def parse(cls, value, default=None): 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", + ), +] diff --git a/docs/sitemaps.py b/docs/sitemaps.py index c6a9ba0c84..6f520aa753 100644 --- a/docs/sitemaps.py +++ b/docs/sitemaps.py @@ -1,6 +1,7 @@ from django.contrib.sitemaps import Sitemap from .models import Document +from .search import DocumentationCategory class DocsSitemap(Sitemap): @@ -10,6 +11,7 @@ def __init__(self, lang): def items(self): return ( Document.objects.filter(release__lang=self.lang) + .exclude(metadata__parents=DocumentationCategory.WEBSITE) .order_by("-release__release", "path") .select_related("release__release") ) diff --git a/docs/templates/docs/search_results.html b/docs/templates/docs/search_results.html index f4308132c6..2df9a8499e 100644 --- a/docs/templates/docs/search_results.html +++ b/docs/templates/docs/search_results.html @@ -43,11 +43,11 @@
{{ name }}
{% if value.module_path %}{% endif %}
diff --git a/docs/tests/test_models.py b/docs/tests/test_models.py
index d17bd4d051..ad858419ff 100644
--- a/docs/tests/test_models.py
+++ b/docs/tests/test_models.py
@@ -4,10 +4,14 @@
from django.conf import settings
from django.db import connection
from django.test import TestCase
+from django.utils import timezone
+from django_hosts import reverse
+from blog.models import Entry
from releases.models import Release
from ..models import Document, DocumentRelease
+from ..search import DocumentationCategory
class ModelsTests(TestCase):
@@ -450,7 +454,24 @@ def test_search_title(self):
class UpdateDocTests(TestCase):
@classmethod
def setUpTestData(cls):
- cls.release = DocumentRelease.objects.create()
+ now = timezone.now()
+ cls.release = DocumentRelease.objects.create(
+ release=Release.objects.create(
+ version="1.0.0",
+ eol_date=now + datetime.timedelta(days=1),
+ )
+ )
+ cls.entry = Entry.objects.create(
+ pub_date=now,
+ is_active=True,
+ is_searchable=True,
+ headline="Searchable post",
+ slug="a",
+ body_html="