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]