Skip to content

Commit c4e488c

Browse files
authored
Merge pull request #1307 from makeabilitylab/1252-add-sitemap
Add dynamic sitemap.xml and robots.txt (#1252)
2 parents 086c02f + 3bdd716 commit c4e488c

6 files changed

Lines changed: 261 additions & 1 deletion

File tree

makeabilitylab/settings.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,14 @@
181181

182182
'django.contrib.humanize', # for humanizing numbers in templates: https://docs.djangoproject.com/en/4.2/ref/contrib/humanize/
183183

184+
# Generates a dynamic /sitemap.xml from our querysets for SEO (issue #1252).
185+
# NOTE: we deliberately do NOT install django.contrib.sites — without it,
186+
# the sitemap framework falls back to RequestSite, deriving the domain from
187+
# the incoming request host. That makes the sitemap emit the correct domain
188+
# across all three environments (local / test / prod) with no per-env config
189+
# and no extra DB migration. See website/sitemaps.py.
190+
'django.contrib.sitemaps',
191+
184192
# Image handling = two cooperating pieces: easy-thumbnails resizes/scales,
185193
# while image_cropping lets editors pick the crop box. easy-thumbnails then
186194
# renders that box at any size on demand (see crop_corners in

makeabilitylab/urls.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,24 @@
2121

2222
from django.urls import include, re_path, path
2323
from django.contrib import admin
24+
from django.contrib.sitemaps.views import sitemap
2425
from django.conf.urls.static import static
2526
from django.views.static import serve
2627
from django.conf import settings
2728

29+
from website import views
30+
from website.sitemaps import sitemaps
31+
2832
urlpatterns = [
29-
33+
3034
re_path(r'^admin/', admin.site.urls),
3135

36+
# SEO endpoints (issue #1252). Declared before the website.urls include so
37+
# the app's patterns can't shadow them. The sitemap is generated from our
38+
# querysets (see website/sitemaps.py); robots.txt is environment-aware.
39+
path('sitemap.xml', sitemap, {'sitemaps': sitemaps}, name='django.contrib.sitemaps.views.sitemap'),
40+
path('robots.txt', views.robots_txt, name='robots_txt'),
41+
3242
#Info on how to route root to website was found here http://stackoverflow.com/questions/7580220/django-urls-howto-map-root-to-app
3343
re_path(r'', include('website.urls')),
3444
# re_path(r'^admin/', admin.site.urls),

website/sitemaps.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""
2+
Sitemap classes that power the dynamic ``/sitemap.xml`` (issue #1252).
3+
4+
Django's ``django.contrib.sitemaps`` framework builds the XML on every request
5+
straight from our querysets, so the sitemap is always current — no static file
6+
to regenerate when content changes.
7+
8+
Domain handling: we do NOT use ``django.contrib.sites``. When it isn't
9+
installed, the framework falls back to ``RequestSite``, which takes the domain
10+
from the incoming request host. That means the same code emits
11+
``makeabilitylab.cs.washington.edu`` in prod, ``makeabilitylab-test...`` on the
12+
test server, and ``localhost`` in dev — no per-environment configuration.
13+
14+
We map only the pages that have real, indexable URLs:
15+
- static listing pages (home, people, publications, projects, awards, news)
16+
- one entry per visible Project -> /project/<short_name>/
17+
- one entry per public Person -> /member/<url_name>/
18+
- one entry per News item -> /news/<slug>/
19+
20+
Publications have no per-publication detail page (only the ``/publications/``
21+
listing), so they are covered by the static sitemap and not enumerated here.
22+
"""
23+
24+
from django.contrib.sitemaps import Sitemap
25+
from django.urls import reverse
26+
27+
from website.models import Project, Person, News
28+
29+
30+
class StaticViewSitemap(Sitemap):
31+
"""Top-level listing pages that aren't tied to a single model instance."""
32+
33+
changefreq = "weekly"
34+
priority = 0.8
35+
36+
def items(self):
37+
# URL names (in the ``website`` namespace) for the public landing pages.
38+
return [
39+
"website:index",
40+
"website:people",
41+
"website:publications",
42+
"website:projects",
43+
"website:awards",
44+
"website:news_listing",
45+
]
46+
47+
def location(self, item):
48+
return reverse(item)
49+
50+
51+
class ProjectSitemap(Sitemap):
52+
"""Public project pages: /project/<short_name>/."""
53+
54+
changefreq = "weekly"
55+
priority = 0.7
56+
57+
def items(self):
58+
# Mirror the project view's visibility rule: only is_visible projects
59+
# are reachable by the public. Explicit ordering keeps sitemap
60+
# pagination stable (avoids UnorderedObjectListWarning).
61+
return Project.objects.filter(is_visible=True).order_by("short_name")
62+
63+
def location(self, obj):
64+
# The project view resolves short_name (see website/views/project.py).
65+
return reverse("website:project", args=[obj.short_name])
66+
67+
def lastmod(self, obj):
68+
# auto_now DateField, updated on every save.
69+
return obj.updated
70+
71+
72+
class PersonSitemap(Sitemap):
73+
"""Public people pages: /member/<url_name>/."""
74+
75+
changefreq = "monthly"
76+
priority = 0.6
77+
78+
def items(self):
79+
# Anyone who has held a position appears on the /people/ page and has a
80+
# public member page. Exclude the 'placeholder' default url_name (people
81+
# whose url_name was never generated) since those won't resolve.
82+
return (
83+
Person.objects.filter(position__isnull=False)
84+
.exclude(url_name="placeholder")
85+
.order_by("url_name")
86+
.distinct()
87+
)
88+
89+
def location(self, obj):
90+
return reverse("website:member_by_name", args=[obj.url_name])
91+
92+
def lastmod(self, obj):
93+
# May be None for people whose bio was never edited; the framework
94+
# simply omits <lastmod> in that case.
95+
return obj.bio_datetime_modified
96+
97+
98+
class NewsSitemap(Sitemap):
99+
"""News item pages: /news/<slug>/."""
100+
101+
changefreq = "monthly"
102+
priority = 0.5
103+
104+
def items(self):
105+
return News.objects.exclude(slug__isnull=True)
106+
107+
def location(self, obj):
108+
return reverse("website:news_item_by_slug", args=[obj.slug])
109+
110+
def lastmod(self, obj):
111+
return obj.date
112+
113+
114+
# Registry passed to django.contrib.sitemaps.views.sitemap in the root URLconf.
115+
sitemaps = {
116+
"static": StaticViewSitemap,
117+
"projects": ProjectSitemap,
118+
"people": PersonSitemap,
119+
"news": NewsSitemap,
120+
}

website/tests/test_sitemap.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""
2+
Regression tests for the dynamic sitemap and robots.txt (issue #1252).
3+
4+
Both endpoints are exercised through the real URL/view stack so a routing or
5+
queryset regression is caught. See website/sitemaps.py and
6+
website/views/robots.py.
7+
"""
8+
9+
import os
10+
from datetime import date
11+
from unittest import mock
12+
13+
from website.tests.base import DatabaseTestCase
14+
15+
16+
class SitemapTests(DatabaseTestCase):
17+
def _make_position(self, person):
18+
"""Give a Person a Position so it appears in the people sitemap."""
19+
from website.models import Position
20+
from website.models.position import Title
21+
return Position.objects.create(
22+
person=person, start_date=date(2020, 1, 1), title=Title.PHD_STUDENT
23+
)
24+
25+
def test_sitemap_returns_xml(self):
26+
resp = self.client.get("/sitemap.xml")
27+
self.assertEqual(resp.status_code, 200)
28+
self.assertIn("xml", resp["Content-Type"])
29+
30+
def test_sitemap_includes_static_pages(self):
31+
body = self.client.get("/sitemap.xml").content.decode()
32+
# Listing pages should always be present.
33+
self.assertIn("/publications/", body)
34+
self.assertIn("/people/", body)
35+
36+
def test_sitemap_includes_visible_project_excludes_private(self):
37+
self.make_project(name="Visible Proj", short_name="visibleproj",
38+
is_visible=True)
39+
self.make_project(name="Private Proj", short_name="privateproj",
40+
is_visible=False)
41+
body = self.client.get("/sitemap.xml").content.decode()
42+
self.assertIn("/project/visibleproj/", body)
43+
self.assertNotIn("/project/privateproj/", body)
44+
45+
def test_sitemap_includes_person_with_position(self):
46+
person = self.make_person(first_name="Ada", last_name="Lovelace")
47+
self._make_position(person)
48+
body = self.client.get("/sitemap.xml").content.decode()
49+
self.assertIn(f"/member/{person.url_name}/", body)
50+
51+
def test_sitemap_excludes_person_without_position(self):
52+
# No position => not on the public people page => not in the sitemap.
53+
person = self.make_person(first_name="Grace", last_name="Hopper")
54+
body = self.client.get("/sitemap.xml").content.decode()
55+
self.assertNotIn(f"/member/{person.url_name}/", body)
56+
57+
def test_sitemap_includes_news_item(self):
58+
news = self.make_news_item(title="Big Lab News")
59+
body = self.client.get("/sitemap.xml").content.decode()
60+
self.assertIn(f"/news/{news.slug}/", body)
61+
62+
63+
class RobotsTxtTests(DatabaseTestCase):
64+
def test_robots_is_plain_text(self):
65+
resp = self.client.get("/robots.txt")
66+
self.assertEqual(resp.status_code, 200)
67+
self.assertEqual(resp["Content-Type"], "text/plain")
68+
69+
@mock.patch.dict(os.environ, {"DJANGO_ENV": "PROD"})
70+
def test_robots_prod_allows_and_advertises_sitemap(self):
71+
body = self.client.get("/robots.txt").content.decode()
72+
self.assertIn("Allow: /", body)
73+
self.assertIn("Sitemap:", body)
74+
self.assertIn("/sitemap.xml", body)
75+
76+
@mock.patch.dict(os.environ, {"DJANGO_ENV": "TEST"})
77+
def test_robots_non_prod_disallows_all(self):
78+
body = self.client.get("/robots.txt").content.decode()
79+
self.assertIn("Disallow: /", body)
80+
self.assertNotIn("Allow: /", body)

website/views/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@
99
from .view_project_people import *
1010
from .serve_pdf import *
1111
from .awards import awards
12+
from .robots import robots_txt
1213

website/views/robots.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""
2+
Serves /robots.txt dynamically (issue #1252).
3+
4+
We serve this from Django (not as a static file at the web-server root) because
5+
the maintainer has no web-server access — routing it through a view is the only
6+
control we have, and it lets us vary behavior by environment.
7+
8+
Two behaviors:
9+
- PROD: allow crawling and advertise the sitemap.
10+
- Everything else (notably the TEST server, DJANGO_ENV=TEST): disallow all
11+
crawling so the test site is never indexed and can't compete with the
12+
production site in search results.
13+
14+
The sitemap URL is built from the request host, so it points at whatever domain
15+
the request came in on (prod / test / localhost).
16+
"""
17+
18+
import os
19+
20+
from django.http import HttpResponse
21+
22+
23+
def robots_txt(request):
24+
"""Return an environment-appropriate robots.txt as text/plain."""
25+
sitemap_url = request.build_absolute_uri("/sitemap.xml")
26+
27+
if os.environ.get("DJANGO_ENV") == "PROD":
28+
lines = [
29+
"User-agent: *",
30+
"Allow: /",
31+
"",
32+
f"Sitemap: {sitemap_url}",
33+
]
34+
else:
35+
# Test / dev: keep the whole site out of search indexes.
36+
lines = [
37+
"User-agent: *",
38+
"Disallow: /",
39+
]
40+
41+
return HttpResponse("\n".join(lines) + "\n", content_type="text/plain")

0 commit comments

Comments
 (0)