Skip to content

Commit 837e808

Browse files
committed
Added support for searching ecosystem and blog entries.
The blog results should have a property of whether it is included in the search results. We should also limit the blogs that are searchable for a version of Django based on the support end. This will allow us to limit the inclusion of blog posts in the search based on the time the entry was created, keeping the search results relevant to that version of Django.
1 parent 12c377e commit 837e808

File tree

5 files changed

+214
-10
lines changed

5 files changed

+214
-10
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 5.2 on 2025-07-23 16:31
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('docs', '0006_alter_document_metadata_noop'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='documentrelease',
15+
name='support_end',
16+
field=models.DateField(blank=True, help_text='The end of support for this release of Django.', null=True),
17+
),
18+
]

docs/models.py

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,13 @@
1818
from django.db import models, transaction
1919
from django.db.models import Q
2020
from django.db.models.fields.json import KeyTextTransform
21+
from django.test import RequestFactory
22+
from django.urls import resolve, reverse as reverse_path
2123
from django.utils.functional import cached_property
2224
from django.utils.html import strip_tags
2325
from django_hosts.resolvers import reverse
2426

27+
from blog.models import Entry
2528
from releases.models import Release
2629

2730
from . import utils
@@ -31,6 +34,7 @@
3134
START_SEL,
3235
STOP_SEL,
3336
TSEARCH_CONFIG_LANGUAGES,
37+
DocumentationCategory,
3438
)
3539

3640

@@ -95,6 +99,11 @@ class DocumentRelease(models.Model):
9599
on_delete=models.CASCADE,
96100
)
97101
is_default = models.BooleanField(default=False)
102+
support_end = models.DateField(
103+
null=True,
104+
blank=True,
105+
help_text="The end of support for this release of Django.",
106+
)
98107

99108
objects = DocumentReleaseQuerySet.as_manager()
100109

@@ -212,6 +221,83 @@ def sync_to_db(self, decoded_documents):
212221
)
213222
document.save(update_fields=("metadata",))
214223

224+
self._sync_blog_to_db()
225+
self._sync_views_to_db()
226+
227+
def _sync_blog_to_db(self):
228+
"""
229+
Sync the blog entries into search based on the release documents
230+
support end date.
231+
"""
232+
if self.lang == "en" and self.support_end:
233+
for entry in Entry.objects.published(self.support_end).searchable():
234+
Document.objects.create(
235+
release=self,
236+
path=entry.get_absolute_url(),
237+
title=entry.headline,
238+
metadata={
239+
"body": entry.body_html,
240+
"breadcrumbs": [
241+
{
242+
"path": DocumentationCategory.WEBSITE.value,
243+
"title": "News",
244+
},
245+
],
246+
"parents": DocumentationCategory.WEBSITE.value,
247+
"slug": entry.slug,
248+
"title": entry.headline,
249+
"toc": "",
250+
},
251+
config=TSEARCH_CONFIG_LANGUAGES.get(
252+
self.lang[:2], DEFAULT_TEXT_SEARCH_CONFIG
253+
),
254+
)
255+
256+
def _sync_views_to_db(self):
257+
"""
258+
Sync the blog entries into search based on the release documents
259+
support end date.
260+
"""
261+
if self.lang == "en":
262+
# The request needs to come through as a valid one, it's best if it
263+
# matches the exact host we're looking for.
264+
www_hosts = [
265+
host for host in settings.ALLOWED_HOSTS if host.startswith("www.")
266+
]
267+
if not www_hosts or not (www_host := www_hosts[0]):
268+
return
269+
synced_views = [
270+
# Page title, url name, url kwargs
271+
("Django's Ecosystem", "community-ecosystem", {}),
272+
]
273+
for title, url_name, kwargs in synced_views:
274+
absolute_url = reverse(url_name, kwargs=kwargs, host="www")
275+
path = reverse_path(url_name, kwargs=kwargs)
276+
request = RequestFactory().get(path, HTTP_HOST=www_host)
277+
body = resolve(path).func(request).render().text
278+
# Need to parse the body element.
279+
Document.objects.create(
280+
release=self,
281+
path=absolute_url,
282+
title=title,
283+
metadata={
284+
"body": body,
285+
"breadcrumbs": [
286+
{
287+
"path": DocumentationCategory.WEBSITE.value,
288+
"title": "Website",
289+
},
290+
],
291+
"parents": DocumentationCategory.WEBSITE.value,
292+
"slug": url_name,
293+
"title": title,
294+
"toc": "",
295+
},
296+
config=TSEARCH_CONFIG_LANGUAGES.get(
297+
self.lang[:2], DEFAULT_TEXT_SEARCH_CONFIG
298+
),
299+
)
300+
215301

216302
def _clean_document_path(path):
217303
# We have to be a bit careful to reverse-engineer the correct
@@ -224,7 +310,9 @@ def _clean_document_path(path):
224310

225311

226312
def document_url(doc):
227-
if doc.path:
313+
if doc.metadata.get("parents") == DocumentationCategory.WEBSITE.value:
314+
return doc.path
315+
elif doc.path:
228316
kwargs = {
229317
"lang": doc.release.lang,
230318
"version": doc.release.version,

docs/search.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ class DocumentationCategory(TextChoices):
6565
TOPICS = "topics", _("Using Django")
6666
HOWTO = "howto", _("How-to guides")
6767
RELEASE_NOTES = "releases", _("Release notes")
68+
WEBSITE = "weblog", _("Django Website")
6869

6970
@classmethod
7071
def parse(cls, value, default=None):

docs/tests/test_models.py

Lines changed: 104 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@
44
from django.conf import settings
55
from django.db import connection
66
from django.test import TestCase
7+
from django.utils import timezone
8+
from django_hosts import reverse
79

10+
from blog.models import Entry
811
from releases.models import Release
912

1013
from ..models import DOCUMENT_SEARCH_VECTOR, Document, DocumentRelease
14+
from ..search import DocumentationCategory
1115

1216

1317
class ModelsTests(TestCase):
@@ -465,7 +469,21 @@ def test_search_title(self):
465469
class UpdateDocTests(TestCase):
466470
@classmethod
467471
def setUpTestData(cls):
468-
cls.release = DocumentRelease.objects.create()
472+
now = timezone.now()
473+
cls.release = DocumentRelease.objects.create(
474+
support_end=now + datetime.timedelta(days=1)
475+
)
476+
cls.entry = Entry.objects.create(
477+
pub_date=now,
478+
is_active=True,
479+
is_searchable=True,
480+
headline="Searchable post",
481+
slug="a",
482+
body_html="<h1>Searchable Blog Post</h1>",
483+
)
484+
cls.docs_documents = cls.release.documents.exclude(
485+
metadata__parents=DocumentationCategory.WEBSITE.value
486+
)
469487

470488
def test_sync_to_db(self):
471489
self.release.sync_to_db(
@@ -477,8 +495,43 @@ def test_sync_to_db(self):
477495
}
478496
]
479497
)
480-
document = self.release.documents.get()
481-
self.assertEqual(document.path, "foo/bar")
498+
document_paths = set(self.release.documents.values_list("path", flat=True))
499+
self.assertEqual(
500+
document_paths,
501+
{
502+
"foo/bar",
503+
reverse("community-ecosystem", host="www"),
504+
self.entry.get_absolute_url(),
505+
},
506+
)
507+
508+
def test_blog_to_db_skip_non_english(self):
509+
"""
510+
Releases must be English to include the blog and website results in search.
511+
"""
512+
non_english = DocumentRelease.objects.create(
513+
lang="es",
514+
release=Release.objects.create(version="88.0"),
515+
support_end=self.release.support_end,
516+
)
517+
non_english.sync_to_db([])
518+
self.assertFalse(non_english.documents.exists())
519+
520+
def test_blog_to_db_skip_no_end_support(self):
521+
"""
522+
Releases must have an end support to include the blog.
523+
"""
524+
no_end_support = DocumentRelease.objects.create(
525+
lang="en",
526+
release=Release.objects.create(version="99.0"),
527+
)
528+
no_end_support.sync_to_db([])
529+
self.assertEqual(
530+
set(no_end_support.documents.values_list("path", flat=True)),
531+
{
532+
reverse("community-ecosystem", host="www"),
533+
},
534+
)
482535

483536
def test_clean_path(self):
484537
self.release.sync_to_db(
@@ -490,7 +543,7 @@ def test_clean_path(self):
490543
}
491544
]
492545
)
493-
document = self.release.documents.get()
546+
document = self.docs_documents.get()
494547
self.assertEqual(document.path, "foo/bar")
495548

496549
def test_title_strip_tags(self):
@@ -504,7 +557,7 @@ def test_title_strip_tags(self):
504557
]
505558
)
506559
self.assertQuerySetEqual(
507-
self.release.documents.all(),
560+
self.docs_documents.all(),
508561
["This is the title"],
509562
transform=attrgetter("title"),
510563
)
@@ -520,7 +573,7 @@ def test_title_entities(self):
520573
]
521574
)
522575
self.assertQuerySetEqual(
523-
self.release.documents.all(),
576+
self.docs_documents,
524577
["Title & title"],
525578
transform=attrgetter("title"),
526579
)
@@ -533,7 +586,7 @@ def test_empty_documents(self):
533586
{"current_page_name": "foo/3"},
534587
]
535588
)
536-
self.assertQuerySetEqual(self.release.documents.all(), [])
589+
self.assertQuerySetEqual(self.docs_documents, [])
537590

538591
def test_excluded_documents(self):
539592
"""
@@ -562,3 +615,47 @@ def test_excluded_documents(self):
562615
)
563616
document = release.documents.get()
564617
self.assertEqual(document.path, "nonexcluded/bar")
618+
619+
620+
class DocumentUrlTests(TestCase):
621+
@classmethod
622+
def setUpTestData(cls):
623+
cls.release = DocumentRelease.objects.create(
624+
release=Release.objects.create(version="1.2.3"),
625+
)
626+
documents = [
627+
{
628+
"metadata": {"parents": "topics http"},
629+
"path": "topics/http/generic-views",
630+
"release": cls.release,
631+
"title": "Generic views",
632+
},
633+
# I'm not sure if this is valid or not.
634+
{
635+
"metadata": {},
636+
"path": "",
637+
"release": cls.release,
638+
"title": "Index",
639+
},
640+
]
641+
# Include the static views in the document search
642+
cls.release._sync_views_to_db()
643+
Document.objects.bulk_create(Document(**doc) for doc in documents)
644+
cls.document_index, cls.document_view, cls.document_detail = (
645+
cls.release.documents.order_by("path")
646+
)
647+
648+
def test_document_url(self):
649+
self.assertEqual(
650+
self.document_index.get_absolute_url(),
651+
"http://docs.djangoproject.localhost:8000/en/1.2.3/",
652+
)
653+
self.assertEqual(
654+
self.document_view.get_absolute_url(),
655+
"http://www.djangoproject.localhost:8000/community/ecosystem/",
656+
)
657+
self.assertEqual(
658+
self.document_detail.get_absolute_url(),
659+
"http://docs.djangoproject.localhost:8000"
660+
"/en/1.2.3/topics/http/generic-views/",
661+
)

docs/tests/test_views.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ def test_search_type_filter_all(self):
9191
)
9292
self.assertEqual(response.status_code, 200)
9393
self.assertContains(
94-
response, "4 results for <em>generic</em> in version 5.1", html=True
94+
response, "5 results for <em>generic</em> in version 5.1", html=True
9595
)
9696
self.assertContains(response, self.active_filter, count=1)
9797
self.assertContains(response, f"{self.active_filter}All</a>", html=True)
@@ -122,7 +122,7 @@ def test_search_category_filter_invalid_doc_categories(self):
122122
)
123123
self.assertEqual(response.status_code, 200)
124124
self.assertContains(
125-
response, "4 results for <em>generic</em> in version 5.1", html=True
125+
response, "5 results for <em>generic</em> in version 5.1", html=True
126126
)
127127
self.assertContains(response, self.active_filter, count=1)
128128
self.assertContains(response, f"{self.active_filter}All</a>", html=True)

0 commit comments

Comments
 (0)