diff --git a/bases/rsptx/assignment_server_api/routers/student.py b/bases/rsptx/assignment_server_api/routers/student.py
index 9f8b2e6fd..d4dc0b641 100644
--- a/bases/rsptx/assignment_server_api/routers/student.py
+++ b/bases/rsptx/assignment_server_api/routers/student.py
@@ -75,6 +75,7 @@
get_jinja_templates,
get_shared_templates,
)
+from rsptx.templates.ebook_config import ebook_config_override
from rsptx.response_helpers import construct_course_url, safe_join
from rsptx.response_helpers.core import (
make_json_response,
@@ -214,6 +215,15 @@ def sort_key(assignment):
origin=course_markup_system,
**course_attrs,
)
+ if use_pretext_student_pages:
+ context["ebook_config_override"] = await ebook_config_override(
+ course=course,
+ user=user,
+ user_is_instructor=user_is_instructor,
+ course_attrs=course_attrs,
+ readings=None, # force bookfuncs to not render assignment tracker
+ activities={},
+ )
return templates.TemplateResponse(
"assignment/student/chooseAssignment.html", context
)
@@ -1096,6 +1106,15 @@ async def doAssignment(
allow_pairs="true" if course.allow_pairs else "false",
**course_attrs,
)
+ if use_pretext_student_pages:
+ context["ebook_config_override"] = await ebook_config_override(
+ course=course,
+ user=user,
+ user_is_instructor=user_is_instructor,
+ course_attrs=course_attrs,
+ readings=None, # force bookfuncs to not render assignment tracker
+ activities={},
+ )
response = templates.TemplateResponse(
"assignment/student/doAssignment.html", context
)
diff --git a/bases/rsptx/book_server_api/routers/books.py b/bases/rsptx/book_server_api/routers/books.py
index 9fc8762f1..e7ba56396 100644
--- a/bases/rsptx/book_server_api/routers/books.py
+++ b/bases/rsptx/book_server_api/routers/books.py
@@ -71,6 +71,7 @@
from rsptx.db.models import UseinfoValidation
from rsptx.auth.session import is_instructor
from rsptx.templates import template_folder
+from rsptx.templates.ebook_config import ebook_config_override
from rsptx.response_helpers import safe_join
from rsptx.response_helpers.core import canonical_utcnow
from typing_extensions import Annotated
@@ -677,6 +678,15 @@ async def serve_page(
worker_name=worker_name,
appname="runestone", # for peer+ links
course_attrs=course_attrs,
+ ebook_config_override=await ebook_config_override(
+ course=course_row,
+ user=user,
+ user_is_instructor=user_is_instructor,
+ course_attrs=course_attrs,
+ readings=reading_list,
+ activities=activity_info,
+ use_services=use_services,
+ ),
**course_attrs,
)
# See `templates `_.
diff --git a/bases/rsptx/book_server_api/routers/course.py b/bases/rsptx/book_server_api/routers/course.py
index 2418550a7..73b44cdbc 100644
--- a/bases/rsptx/book_server_api/routers/course.py
+++ b/bases/rsptx/book_server_api/routers/course.py
@@ -19,6 +19,7 @@
from rsptx.auth.session import auth_manager
from rsptx.templates import get_jinja_templates, get_shared_templates
+from rsptx.templates.ebook_config import ebook_config_override
from rsptx.db.crud import (
fetch_assignments,
fetch_all_assignment_stats,
@@ -152,35 +153,42 @@ def sort_key(assignment):
else:
templates = get_shared_templates()
- return templates.TemplateResponse(
- "book/course/current_course.html",
- {
- "assignment_list": assignments,
- "stats": stats,
- "course": course,
- "is_old_course": course.term_start_date
- < (datetime.datetime.now() - datetime.timedelta(weeks=52)).date(),
- "user": user,
- "request": request,
- "institution": course.institution,
- "instructor_list": instructors,
- "base_course": course.base_course,
- "origin": course_markup_system,
- "book": book,
- "lastPageUrl": last_page_url,
- "student_page": True,
- "course_list": course_list,
- "is_instructor": "true" if user_is_instructor else "false",
- "has_discussion_group": book.social_url if book else None,
- "lti1p1": is_lti1p1_course,
- "now": now,
- "visibility_map": visibility_map,
- "course_attrs": attrs,
- "course_name": course.course_name,
- "base_url": construct_course_url(course),
- "settings": settings,
- },
- )
+ context = {
+ "assignment_list": assignments,
+ "stats": stats,
+ "course": course,
+ "is_old_course": course.term_start_date
+ < (datetime.datetime.now() - datetime.timedelta(weeks=52)).date(),
+ "user": user,
+ "request": request,
+ "institution": course.institution,
+ "instructor_list": instructors,
+ "base_course": course.base_course,
+ "origin": course_markup_system,
+ "book": book,
+ "lastPageUrl": last_page_url,
+ "student_page": True,
+ "course_list": course_list,
+ "is_instructor": "true" if user_is_instructor else "false",
+ "has_discussion_group": book.social_url if book else None,
+ "lti1p1": is_lti1p1_course,
+ "now": now,
+ "visibility_map": visibility_map,
+ "course_attrs": attrs,
+ "course_name": course.course_name,
+ "base_url": construct_course_url(course),
+ "settings": settings,
+ }
+ if use_pretext_student_pages:
+ context["ebook_config_override"] = await ebook_config_override(
+ course=course,
+ user=user,
+ user_is_instructor=user_is_instructor,
+ course_attrs=attrs,
+ readings=[],
+ activities={},
+ )
+ return templates.TemplateResponse("book/course/current_course.html", context)
@router.api_route("/changeCourse/{course_name:str}", methods=["GET", "POST"])
diff --git a/bases/rsptx/interactives/runestone/common/js/bookfuncs.js b/bases/rsptx/interactives/runestone/common/js/bookfuncs.js
index bc1d38943..ce8a31437 100644
--- a/bases/rsptx/interactives/runestone/common/js/bookfuncs.js
+++ b/bases/rsptx/interactives/runestone/common/js/bookfuncs.js
@@ -32,6 +32,23 @@
import RunestoneBase from "./runestonebase.js";
import { marked } from "marked";
+// ---------------------------------------------------------------------------
+// Server-supplied eBookConfig values (issue #1392)
+//
+// A PreTeXt page served by a Runestone server sets ``ebook_config_override``
+// right after the book template's own eBookConfig assignments. The server is
+// the authority for those values (course, user, activities, ...), so they win
+// over whatever the template wrote. This runs when the module is evaluated,
+// before anything else reads eBookConfig.
+// ---------------------------------------------------------------------------
+export function applyEbookConfigOverride() {
+ const override = window.ebook_config_override;
+ if (override && typeof override === "object") {
+ window.eBookConfig = Object.assign(window.eBookConfig || {}, override);
+ }
+}
+applyEbookConfigOverride();
+
var rb = new RunestoneBase();
// ---------------------------------------------------------------------------
@@ -1701,6 +1718,9 @@ window.addEventListener("DOMContentLoaded", function (event) {
let authorUrl =
(eBookConfig.authorServerUrl ||
"https://author.runestone.academy/author") + "/";
+ let editorUrl =
+ eBookConfig.editorServerUrl || "/admin/editor/manage_exercises";
+
if (eBookConfig.isAuthor) {
menuContentArea.appendChild(
makeLink(authorUrl, "Author Dashboard"),
@@ -1708,7 +1728,7 @@ window.addEventListener("DOMContentLoaded", function (event) {
}
if (eBookConfig.isEditor) {
menuContentArea.appendChild(
- makeLink(authorUrl, "Editor Dashboard"),
+ makeLink(editorUrl, "Editor Dashboard"),
);
}
menuContentArea.appendChild(
diff --git a/bases/rsptx/interactives/runestone/common/test/bookfuncs_config_override.test.js b/bases/rsptx/interactives/runestone/common/test/bookfuncs_config_override.test.js
new file mode 100644
index 000000000..c12a0f34b
--- /dev/null
+++ b/bases/rsptx/interactives/runestone/common/test/bookfuncs_config_override.test.js
@@ -0,0 +1,66 @@
+/**
+ * Tests for merging the server's ``ebook_config_override`` into eBookConfig
+ * (issue #1392). The book server injects the override after the PreTeXt
+ * template's own eBookConfig assignments; its values must win.
+ */
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+
+import { applyEbookConfigOverride } from "../js/bookfuncs.js";
+
+describe("applyEbookConfigOverride", () => {
+ let saved;
+
+ beforeEach(() => {
+ saved = window.eBookConfig;
+ delete window.ebook_config_override;
+ });
+
+ afterEach(() => {
+ window.eBookConfig = saved;
+ delete window.ebook_config_override;
+ });
+
+ it("overrides template values and keeps the ones it doesn't name", () => {
+ window.eBookConfig = {
+ course: "from_template",
+ isLoggedIn: false,
+ enableScratchAC: true,
+ };
+ window.ebook_config_override = {
+ course: "from_server",
+ isLoggedIn: true,
+ activities: { page: 0 },
+ };
+ applyEbookConfigOverride();
+ expect(window.eBookConfig).toEqual({
+ course: "from_server",
+ isLoggedIn: true,
+ enableScratchAC: true,
+ activities: { page: 0 },
+ });
+ });
+
+ it("extends the existing object rather than replacing it", () => {
+ const config = { course: "c" };
+ window.eBookConfig = config;
+ window.ebook_config_override = { username: "u" };
+ applyEbookConfigOverride();
+ expect(window.eBookConfig).toBe(config);
+ expect(config.username).toBe("u");
+ });
+
+ it("leaves eBookConfig alone when there is no override", () => {
+ const config = { course: "c" };
+ window.eBookConfig = config;
+ applyEbookConfigOverride();
+ expect(window.eBookConfig).toBe(config);
+ expect(config).toEqual({ course: "c" });
+ });
+
+ it("creates eBookConfig if the page didn't define one", () => {
+ window.eBookConfig = undefined;
+ window.ebook_config_override = { course: "c" };
+ applyEbookConfigOverride();
+ expect(window.eBookConfig).toEqual({ course: "c" });
+ });
+});
diff --git a/components/rsptx/db/crud/group.py b/components/rsptx/db/crud/group.py
index c329bf06b..ff45fbdbf 100644
--- a/components/rsptx/db/crud/group.py
+++ b/components/rsptx/db/crud/group.py
@@ -79,6 +79,9 @@ async def is_author(userid: int) -> bool:
:rtype: bool
"""
ed = await fetch_group("author")
+ if ed is None:
+ # No author group has been created in this database, so nobody is one.
+ return False
row = await fetch_membership(ed.id, userid)
if row:
diff --git a/components/rsptx/templates/ebook_config.py b/components/rsptx/templates/ebook_config.py
new file mode 100644
index 000000000..4a46d2397
--- /dev/null
+++ b/components/rsptx/templates/ebook_config.py
@@ -0,0 +1,68 @@
+# *****************************************************
+# |docname| - server-supplied eBookConfig values
+# *****************************************************
+# A PreTeXt book's ``_base.html`` sets up ``eBookConfig`` from template
+# variables, then emits ``{{ ebook_config_override | safe }}``. ``bookfuncs.js``
+# merges that object into ``eBookConfig`` before anything reads it, so the
+# server, not the book's template, decides the values only a Runestone server
+# can know: the green items in issue #1392.
+#
+# enableScratchAC and acDefaultLanguage are also green there, but they are
+# publisher options with no server-side source, so the book keeps them.
+from typing import Any, Optional
+
+from jinja2.utils import htmlsafe_json_dumps
+
+from rsptx.db.crud import is_author, is_editor
+
+
+async def ebook_config_override(
+ *,
+ course: Any,
+ user: Optional[Any],
+ user_is_instructor: bool,
+ course_attrs: dict,
+ readings: Optional[list],
+ activities: dict,
+ use_services: bool = True,
+) -> str:
+ """Return the ``ebook_config_override`` script statement for a page.
+
+ :param course: the course row being viewed
+ :param user: the logged-in user, or None
+ :param user_is_instructor: whether ``user`` instructs ``course``
+ :param course_attrs: the course's attributes
+ :param readings: the page's reading list; None (JSON ``null``) keeps
+ bookfuncs from drawing the assignment reading tracker
+ :param activities: the activity counts for the progress bar
+ :param use_services: False for a book page viewed in browsing mode
+ """
+ # The Author and Editor Dashboard links only appear in the instructor menu,
+ # so only instructors pay for the group lookups.
+ if user and user_is_instructor:
+ user_is_author = await is_author(user.id)
+ user_is_editor = await is_editor(user.id)
+ else:
+ user_is_author = user_is_editor = False
+
+ ebook_attrs = dict(
+ useRunestoneServices=use_services,
+ course=course.course_name,
+ termStartDate=course.term_start_date.isoformat(),
+ basecourse=course.base_course,
+ isLoggedIn=user is not None,
+ email=user.email if user else "",
+ isInstructor=user_is_instructor,
+ logLevel=10,
+ username=user.username if user else "",
+ readings=readings,
+ activities=activities,
+ downloadsEnabled=bool(course.downloads_enabled),
+ allow_pairs=bool(course.allow_pairs),
+ course_attrs=course_attrs,
+ isEditor=user_is_editor,
+ isAuthor=user_is_author,
+ )
+ # htmlsafe_json_dumps escapes <, >, & and ' so a course attribute can't
+ # close the "},
+ readings=[],
+ activities={},
+ )
+ assert "" not in statement
+ assert _parse(statement)["course_attrs"]["x"].startswith("")
diff --git a/uv.lock b/uv.lock
index 8b4137503..354198955 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2902,7 +2902,7 @@ text = [
[[package]]
name = "pretext"
-version = "2.55.0"
+version = "2.56.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "citeproc-py" },
@@ -2923,9 +2923,9 @@ dependencies = [
{ name = "requests" },
{ name = "single-version" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/ed/08/84c806756a32c5f6ec4febfc42ac255e864b5f874189a3d3e8af0fbee230/pretext-2.55.0.tar.gz", hash = "sha256:80ab59e1be9a5507fe08fcc340691928fa78572ea8f2cf6437d2e019a546d616", size = 19244342, upload-time = "2026-10-01T06:25:17.326Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/26/16/844784e55845685290de3dfe0c09fae65a7e79ddab9348303a18e95eed42/pretext-2.56.0.tar.gz", hash = "sha256:b937c8e870a299db71515b2404a8b544b45f8e348676ba2f84e89c46d6a562d8", size = 19315104, upload-time = "2026-10-20T19:42:57.439Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/92/bb/3dc610084b32f1eab4fc1d6d9d5f718fe79bf3b25dc1fea14c1e78550792/pretext-2.55.0-py3-none-any.whl", hash = "sha256:31a9782df74ed1e8e467a58d1b204c1f4556bfb52f7b9fa8a337cc848ac5abed", size = 18520579, upload-time = "2026-10-01T06:25:14.267Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/49/348483e987c81bc4683f0743bf11319a6ae4733dd525e9be1e07fcac1291/pretext-2.56.0-py3-none-any.whl", hash = "sha256:57e874b94ab38f33576cf48f74488fb98ead461419f38779acd8436bcd2bd811", size = 18554010, upload-time = "2026-10-20T19:42:54.992Z" },
]
[package.optional-dependencies]