Skip to content

Commit d4d2c22

Browse files
Restore the main-site homepage at root
Closes #95
1 parent 16e7c0d commit d4d2c22

11 files changed

Lines changed: 364 additions & 31 deletions

File tree

_docs/adoption/course-platform/integration-patches.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ Copied CMP files remain byte-identical to the pinned source. Integration is conf
77
roots out of autoformat/static-style rewrites while explicitly checking target-owned integration
88
shims; copied behavior is instead guarded by checksums and its unchanged characterization suite;
99
- `website/settings/base.py` and `website/settings/test.py`: adopted apps, middleware/context processor, CustomUser identity, compatibility/provider defaults, dedicated copied-template directory, and test-safe behavior;
10-
- `website/urls.py`: source-compatible HTML, account, API, cadmin, admin/loginas routes alongside the unified scaffold routes;
10+
- `website/urls.py`: source-compatible HTML, account, API, cadmin, admin/loginas routes alongside
11+
the unified scaffold routes; issue #95 restores the main-site root, moves global course discovery
12+
to `/courses/`, exposes the complete copied URLconf under the explicit `courses` namespace, and
13+
retains global non-empty legacy course reversals/inbound paths without modifying the copied
14+
URLconf or its outbound-link behavior;
1115
- `website/admin_api_urls.py`: target-owned compatibility shim preserving the scaffold's
1216
namespaced `/api/v1/admin/health` route while the adopted compatibility API remains at `/api/`;
1317
- `website/admin_api_views.py`: target-owned compatibility shim preserving the scaffold's

core/static/core/site.css

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,37 @@
88
* { box-sizing: border-box; }
99
body { margin: 0; min-height: 100vh; display: flex; flex-direction: column; }
1010
a { color: #0b5fff; }
11-
.site-header, footer { display: flex; align-items: center; justify-content: space-between; padding: 1.25rem clamp(1rem, 5vw, 5rem); background: #fff; }
11+
.skip-link { position: fixed; top: .5rem; left: .5rem; z-index: 10; padding: .75rem 1rem; border-radius: .45rem; background: #17223b; color: #fff; transform: translateY(-150%); }
12+
.skip-link:focus { transform: translateY(0); }
13+
.site-header, .site-footer { display: flex; align-items: center; justify-content: space-between; gap: 1.5rem; padding: 1.25rem clamp(1rem, 5vw, 5rem); background: #fff; }
1214
.site-header { border-bottom: 1px solid #dce2ee; }
1315
.brand { color: #17223b; font-weight: 750; text-decoration: none; font-size: 1.15rem; }
14-
main { width: min(72rem, 100%); margin: 0 auto; padding: clamp(2rem, 8vw, 7rem) clamp(1rem, 5vw, 5rem); flex: 1; }
15-
.hero { max-width: 48rem; }
16+
.primary-navigation, .site-footer nav { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: .45rem 1.2rem; }
17+
.primary-navigation a, .site-footer a { color: #36415a; font-weight: 650; text-decoration: none; }
18+
.primary-navigation a:hover, .site-footer a:hover { color: #0b5fff; text-decoration: underline; text-underline-offset: .25rem; }
19+
main { width: min(76rem, 100%); margin: 0 auto; padding: clamp(2.5rem, 7vw, 6.5rem) clamp(1rem, 5vw, 5rem); flex: 1; }
20+
.hero { max-width: 64rem; }
1621
.eyebrow { color: #0b5fff; font-size: .82rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
1722
h1 { margin: .4rem 0 1rem; font-size: clamp(2.35rem, 8vw, 5.5rem); line-height: .98; letter-spacing: -.045em; }
23+
.hero h2 { max-width: 54rem; margin: 0; color: #293650; font-size: clamp(1.35rem, 3vw, 2.15rem); font-weight: 600; line-height: 1.25; letter-spacing: -.025em; }
1824
.lede { max-width: 42rem; font-size: clamp(1.15rem, 2vw, 1.45rem); line-height: 1.6; color: #44516a; }
19-
.foundation-note { display: inline-block; margin-top: 1.5rem; padding: .75rem 1rem; border-radius: .6rem; background: #e8efff; }
25+
.hero-actions { display: flex; flex-wrap: wrap; gap: .75rem; margin-top: 2rem; }
26+
.button { display: inline-flex; align-items: center; justify-content: center; min-height: 3rem; padding: .75rem 1.1rem; border: 1px solid #0b5fff; border-radius: .5rem; font-weight: 750; text-decoration: none; }
27+
.button.primary { background: #0b5fff; color: #fff; }
28+
.button.secondary { background: #fff; color: #0b5fff; }
29+
.value-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-top: clamp(3.5rem, 8vw, 7rem); }
30+
.value-grid article { min-height: 15rem; padding: clamp(1.25rem, 3vw, 2rem); border: 1px solid #dce2ee; border-radius: 1rem; background: #fff; box-shadow: 0 .75rem 2.25rem rgba(23, 34, 59, .06); }
31+
.value-grid h3 { margin: 2.5rem 0 .75rem; font-size: 1.35rem; }
32+
.value-grid article p:last-child { color: #526078; line-height: 1.65; }
33+
.card-number { margin: 0; color: #0b5fff; font-size: .8rem; font-weight: 800; letter-spacing: .08em; }
34+
.explore-section { margin-top: clamp(4rem, 8vw, 7rem); padding-top: clamp(2rem, 5vw, 4rem); border-top: 1px solid #dce2ee; }
35+
.section-heading { max-width: 44rem; }
36+
.section-heading h2 { margin: .5rem 0 0; font-size: clamp(1.8rem, 4vw, 3.2rem); line-height: 1.1; letter-spacing: -.035em; }
37+
.explore-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .75rem; margin-top: 2rem; }
38+
.explore-grid a { display: grid; gap: .35rem; padding: 1.15rem 1.25rem; border: 1px solid #dce2ee; border-radius: .7rem; background: #fff; color: #17223b; text-decoration: none; }
39+
.explore-grid a:hover { border-color: #0b5fff; box-shadow: 0 .4rem 1.4rem rgba(11, 95, 255, .1); }
40+
.explore-grid strong { color: #0b5fff; font-size: 1.05rem; }
41+
.explore-grid span { color: #526078; line-height: 1.5; }
2042
.panel { padding: clamp(1.5rem, 5vw, 3rem); border: 1px solid #dce2ee; border-radius: 1rem; background: #fff; box-shadow: 0 1rem 3rem rgba(23, 34, 59, .08); }
2143
.panel.narrow { max-width: 32rem; margin: 0 auto; }
2244
.panel h1 { font-size: clamp(2rem, 6vw, 3.5rem); }
@@ -31,7 +53,7 @@ button.secondary { color: #17223b; background: #e8edf5; }
3153
.status-grid div { padding: 1rem; border-radius: .65rem; background: #f5f7fb; }
3254
.status-grid dt { color: #65708a; font-size: .85rem; }
3355
.status-grid dd { margin: .25rem 0 0; font-weight: 750; }
34-
footer { color: #65708a; border-top: 1px solid #dce2ee; }
56+
.site-footer { color: #65708a; border-top: 1px solid #dce2ee; }
3557
.studio-navigation { display: flex; flex-wrap: wrap; gap: .75rem; margin: 1rem 0 2rem; }
3658
.studio-navigation a { padding: .6rem .8rem; border-radius: .45rem; background: #e8efff; font-weight: 700; }
3759
.audit-filters { grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); align-items: end; }
@@ -51,6 +73,14 @@ footer { color: #65708a; border-top: 1px solid #dce2ee; }
5173
.one-time-secret code { display: block; margin: 1rem 0; padding: .8rem; border-radius: .45rem; background: #17223b; color: #fff; overflow-wrap: anywhere; }
5274

5375
@media (max-width: 36rem) {
54-
.site-header { align-items: flex-start; gap: 1rem; }
76+
.site-header, .site-footer { align-items: flex-start; flex-direction: column; }
77+
.primary-navigation, .site-footer nav { justify-content: flex-start; }
5578
main { padding-top: 3.5rem; }
79+
.value-grid, .explore-grid { grid-template-columns: 1fr; }
80+
}
81+
82+
@media (min-width: 36.01rem) and (max-width: 56rem) {
83+
.site-header { align-items: flex-start; }
84+
.value-grid { grid-template-columns: 1fr; }
85+
.value-grid article { min-height: auto; }
5686
}

core/tests/test_course_platform_adoption.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from django.apps import apps
66
from django.core.management import get_commands, load_command_class
77
from django.test import SimpleTestCase
8-
from django.urls import resolve
8+
from django.urls import resolve, reverse
99

1010
from scripts.render_course_platform_inventory import (
1111
SOURCE_APP_LABELS,
@@ -109,7 +109,10 @@ def test_every_adopted_route_resolves_through_the_unified_urlconf(self):
109109
self.assertEqual(len(routes), 89)
110110
for route in routes:
111111
with self.subTest(route=route.route, name=route.name):
112-
match = resolve(route.example_path())
112+
example_path = route.example_path()
113+
if route.surface == "Public courses" and route.name == "course_list":
114+
example_path = reverse("course_list")
115+
match = resolve(example_path)
113116
self.assertEqual(match.url_name, route.name or None)
114117
callback_name = f"{match.func.__module__}.{match.func.__name__}"
115118
self.assertEqual(callback_name, route.callback)

core/tests/test_deployment_release.py

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1061,13 +1061,24 @@ def test_http_smoke_checks_safe_404_and_writes_only_redacted_evidence(self) -> N
10611061
Response(
10621062
200,
10631063
noindex,
1064-
b"Learn data skills. For free. Together."
1065-
b'<link rel="stylesheet" href="/static/courses.fixture.css">',
1064+
b"<title>Welcome to DataTalks.Club</title>"
1065+
b"The place to talk about data"
1066+
b"Global online community of data science professionals, ML engineers, "
1067+
b"and AI practitioners"
1068+
b'<link rel="canonical" href="https://datatalks.club/">'
1069+
b'<link rel="stylesheet" href="/static/core.fixture.css">',
10661070
),
10671071
Response(
10681072
200,
10691073
noindex,
1070-
b'Foundation page<link rel="canonical" href="https://datatalks.club/">',
1074+
b"The place to talk about data"
1075+
b'<link rel="canonical" href="https://datatalks.club/">',
1076+
),
1077+
Response(
1078+
200,
1079+
noindex,
1080+
b"Learn data skills. For free. Together."
1081+
b'<link rel="stylesheet" href="/static/courses.fixture.css">',
10711082
),
10721083
Response(
10731084
302,
@@ -1111,7 +1122,7 @@ def test_http_smoke_checks_safe_404_and_writes_only_redacted_evidence(self) -> N
11111122
"/__dtc_deployed_smoke_missing__",
11121123
"/robots.txt",
11131124
"/sitemap.xml",
1114-
"/static/courses.fixture.css",
1125+
"/static/core.fixture.css",
11151126
],
11161127
)
11171128
persisted = path.read_text()
@@ -1120,6 +1131,38 @@ def test_http_smoke_checks_safe_404_and_writes_only_redacted_evidence(self) -> N
11201131
for forbidden in ("cookie", "authorization", "set-cookie", "response_body"):
11211132
self.assertNotIn(forbidden, persisted.lower())
11221133

1134+
invalid_surface_responses = (
1135+
(
1136+
2,
1137+
Response(
1138+
200,
1139+
noindex,
1140+
b"Learn data skills. For free. Together."
1141+
b'<link rel="stylesheet" href="/static/core.fixture.css">',
1142+
),
1143+
"home page lacks expected content",
1144+
),
1145+
(
1146+
4,
1147+
Response(
1148+
200,
1149+
noindex,
1150+
b"The place to talk about data"
1151+
b'<link rel="stylesheet" href="/static/courses.fixture.css">',
1152+
),
1153+
"course discovery lacks expected content",
1154+
),
1155+
)
1156+
for response_index, invalid_response, error_message in invalid_surface_responses:
1157+
with self.subTest(error_message=error_message):
1158+
invalid_responses = [*responses]
1159+
invalid_responses[response_index] = invalid_response
1160+
with (
1161+
patch("deploy.smoke._request", side_effect=invalid_responses),
1162+
self.assertRaisesMessage(ReleaseContractError, error_message),
1163+
):
1164+
run_http_smoke("https://web.dtcdev.click", SHA_A)
1165+
11231166
invalid_admin_responses = (
11241167
(
11251168
Response(
@@ -1149,7 +1192,7 @@ def test_http_smoke_checks_safe_404_and_writes_only_redacted_evidence(self) -> N
11491192
for admin_response, error_message in invalid_admin_responses:
11501193
with self.subTest(error_message=error_message):
11511194
invalid_responses = [*responses]
1152-
invalid_responses[6] = admin_response
1195+
invalid_responses[7] = admin_response
11531196
with (
11541197
patch("deploy.smoke._request", side_effect=invalid_responses),
11551198
self.assertRaisesMessage(ReleaseContractError, error_message),

core/tests/test_homepage.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
from django.test import TestCase
2+
from django.urls import resolve, reverse
3+
4+
from core import views as core_views
5+
from courses.models.course import Course
6+
from courses.views import course_list
7+
8+
9+
class MainHomepageRoutingTests(TestCase):
10+
def test_root_and_unified_alias_use_the_distinct_main_site_shell(self) -> None:
11+
self.assertEqual(reverse("home"), "/")
12+
self.assertEqual(reverse("unified-home"), "/unified/")
13+
self.assertIs(resolve("/").func, core_views.home)
14+
15+
for path in (reverse("home"), reverse("unified-home")):
16+
with self.subTest(path=path):
17+
response = self.client.get(path)
18+
self.assertEqual(response.status_code, 200)
19+
self.assertEqual(response.headers["X-Robots-Tag"], "noindex, nofollow")
20+
self.assertContains(response, "<title>Welcome to DataTalks.Club</title>")
21+
self.assertContains(response, "The place to talk about data")
22+
self.assertContains(
23+
response,
24+
"Global online community of data science professionals, ML engineers, "
25+
"and AI practitioners",
26+
)
27+
self.assertContains(
28+
response,
29+
'<link rel="canonical" href="https://datatalks.club/">',
30+
count=1,
31+
)
32+
self.assertContains(response, "/static/core/site.css")
33+
self.assertNotContains(response, "Learn data skills. For free. Together.")
34+
self.assertNotContains(response, "cdn.tailwindcss.com")
35+
self.assertNotContains(response, "googletagmanager")
36+
self.assertNotContains(response, "google-analytics")
37+
38+
def test_main_navigation_uses_working_transitional_destinations(self) -> None:
39+
response = self.client.get(reverse("home"))
40+
41+
for destination in (
42+
"https://datatalks.club/articles.html",
43+
"https://datatalks.club/slack.html",
44+
"https://datatalks.club/events.html",
45+
"https://datatalks.club/podcast.html",
46+
"https://datatalks.club/podwiki/",
47+
"https://datatalks.club/books.html",
48+
reverse("course_list"),
49+
):
50+
with self.subTest(destination=destination):
51+
self.assertContains(response, f'href="{destination}"')
52+
53+
def test_course_discovery_moves_forward_under_courses_namespace(self) -> None:
54+
self.assertEqual(reverse("course_list"), "/courses/")
55+
self.assertIs(resolve("/courses/").func, course_list.course_list)
56+
57+
response = self.client.get(reverse("course_list"))
58+
59+
self.assertEqual(response.status_code, 200)
60+
self.assertContains(response, "Learn data skills. For free. Together.")
61+
self.assertNotContains(response, "The place to talk about data")
62+
self.assertNotContains(response, 'rel="canonical"')
63+
64+
def test_nonempty_legacy_course_path_remains_an_inbound_alias(self) -> None:
65+
course = Course.objects.create(
66+
title="Compatibility course",
67+
slug="compatibility-course",
68+
description="Legacy inbound routing fixture",
69+
visible=True,
70+
)
71+
forward_path = reverse("courses:course", kwargs={"course_slug": course.slug})
72+
legacy_path = reverse("course", kwargs={"course_slug": course.slug})
73+
74+
self.assertEqual(forward_path, "/courses/compatibility-course/")
75+
self.assertEqual(legacy_path, "/compatibility-course/")
76+
self.assertEqual(self.client.get(forward_path).status_code, 200)
77+
self.assertEqual(self.client.get(legacy_path).status_code, 200)
78+
79+
def test_unknown_legacy_shaped_path_is_a_real_404(self) -> None:
80+
response = self.client.get("/not-a-course/")
81+
82+
self.assertEqual(response.status_code, 404)
83+
self.assertEqual(response.headers["X-Robots-Tag"], "noindex, nofollow")
84+
self.assertNotContains(response, "Traceback", status_code=404)
85+
self.assertNotContains(response, 'rel="canonical"', status_code=404)

deploy/smoke.py

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -127,11 +127,18 @@ def run_http_smoke(
127127
_assert_status(home, 200, "/")
128128
_assert_noindex(home, "/")
129129
html = home.body.decode("utf-8")
130-
for expected in ("Learn data skills. For free. Together.",):
130+
for expected in (
131+
"Welcome to DataTalks.Club",
132+
"The place to talk about data",
133+
"Global online community of data science professionals, ML engineers, and AI practitioners",
134+
):
131135
if expected not in html:
132136
raise ReleaseContractError(f"home page lacks expected content: {expected}")
133-
if 'rel="canonical"' in html:
134-
raise ReleaseContractError("adopted course discovery has a guessed canonical")
137+
canonical = '<link rel="canonical" href="https://datatalks.club/">'
138+
if html.count(canonical) != 1 or html.count('rel="canonical"') != 1:
139+
raise ReleaseContractError("home page production canonical differs")
140+
if "Learn data skills. For free. Together." in html:
141+
raise ReleaseContractError("home page regressed to adopted course discovery")
135142
lowered = html.lower()
136143
if "traceback" in lowered or "page not found" in lowered or "debug=true" in lowered:
137144
raise ReleaseContractError("home page contains debug or 404 output")
@@ -145,9 +152,21 @@ def run_http_smoke(
145152
_assert_status(mapped, 200, "/unified/")
146153
_assert_noindex(mapped, "/unified/")
147154
mapped_html = mapped.body.decode("utf-8")
148-
canonical = '<link rel="canonical" href="https://datatalks.club/">'
149155
if mapped_html.count(canonical) != 1 or mapped_html.count('rel="canonical"') != 1:
150156
raise ReleaseContractError("explicit production canonical differs")
157+
if "The place to talk about data" not in mapped_html:
158+
raise ReleaseContractError("unified compatibility page lacks the main-site identity")
159+
160+
courses = _request(origin, "/courses/")
161+
_assert_status(courses, 200, "/courses/")
162+
_assert_noindex(courses, "/courses/")
163+
courses_html = courses.body.decode("utf-8")
164+
if "Learn data skills. For free. Together." not in courses_html:
165+
raise ReleaseContractError("course discovery lacks expected content")
166+
if "The place to talk about data" in courses_html:
167+
raise ReleaseContractError("course discovery regressed to the main-site home")
168+
if 'rel="canonical"' in courses_html:
169+
raise ReleaseContractError("adopted course discovery has a guessed canonical")
151170

152171
studio = _request(origin, "/studio/")
153172
if studio.status not in {301, 302, 303, 307, 308}:
@@ -232,13 +251,26 @@ def run_http_smoke(
232251
"database": True,
233252
"migrations": True,
234253
},
235-
{"path": "/", "status": 200, "noindex": True, "canonical_absent": True},
254+
{
255+
"path": "/",
256+
"status": 200,
257+
"noindex": True,
258+
"main_site_identity": True,
259+
"explicit_canonical": True,
260+
},
236261
{
237262
"path": "/unified/",
238263
"status": 200,
239264
"noindex": True,
240265
"explicit_canonical": True,
241266
},
267+
{
268+
"path": "/courses/",
269+
"status": 200,
270+
"noindex": True,
271+
"course_discovery": True,
272+
"canonical_absent": True,
273+
},
242274
{
243275
"path": "/studio/",
244276
"status": studio.status,

0 commit comments

Comments
 (0)