books.py - Serve pages from a book
docs to write: how this works…
Imports
These are listed in the order prescribed by PEP 8.
Standard library
Third-party imports
Local application imports
Routing
Setup the router object for the endpoints defined in this file. These will be connected to the main application in main.py - Define the BookServer.
shortcut so we don’t have to repeat this part
groups all logger tags together in the docs.
Options for static asset renderers:
StaticFiles. However, this assumes the static routes are known a priori, in contrast to books (with their static assets) that are dynamically added and removed.
Manually route static files, returning them using a FileResponse. This is the approach taken.
for paths like: /books/published/basecourse/_static/rest
.
If it is fast and efficient to handle it here it would be great. We currently avoid
any static file contact with web2py and handle static files upstream with nginx directly; therefore, this is useful only for testing/a non-production environment.
Note the use of the path`
type for filepath in the decoration. If you don’t use path it
seems to only get you the next
part of the path /pre/vious/next/the/rest
.
TODO: make published/draft configurable
Get the course row so we can use the base_course We would like to serve book pages with the actual course name in the URL instead of the base course. This is a necessary step.
course_row = await fetch_course(course)
if not course_row:
raise HTTPException(404)
filepath = safe_join(
settings.book_path,
course_row.base_course,
"published",
course_row.base_course,
kind,
filepath,
)
rslogger.debug(f"GETTING: {filepath}")
if os.path.exists(filepath):
return FileResponse(filepath)
else:
raise HTTPException(404)
Runestone academy supported several additional static folders: _static|_images|images|_downloads|generated|external There must be a better solution than duplicating this code X times there is probabaly some fancy decorator trick but this is quick and easy. TODO: Routes for draft (instructor-only) books.
@router.get("/published/{course:str}/_images/{filepath:path}")
async def get_image(course: str, filepath: str):
return await return_static_asset(course, "_images", filepath)
@router.get("/published/{course:str}/_static/{filepath:path}")
async def get_static(course: str, filepath: str):
return await return_static_asset(course, "_static", filepath)
PreTeXt books put images in images not _images – oh for regexes in routes!
Umich book uses the _downloads folder and :download:
role
PreTeXt
PreTeXt
Basic page renderer
To see the output of this endpoint, see http://localhost:8080/books/published/overview/index.html. the course_name in the uri is the actual course name, not the base course, as was previously the case. This should help eliminate the accidental work in the base course problem, and allow teachers to share links to their course with the students.
@router.api_route(
"/published/{course_name:str}/{pagepath:path}",
methods=["GET", "POST"],
response_class=HTMLResponse,
)
async def serve_page(
request: Request,
course_name: constr(max_length=512), # type: ignore
pagepath: constr(max_length=512), # type: ignore
RS_info: Optional[str] = Cookie(None),
mode: Optional[str] = None,
):
if mode and mode == "browsing":
use_services = False
user = None
else:
use_services = True
user = request.state.user
rslogger.debug(f"user = {user}, course name = {course_name}")
Make sure this course exists, and look up its base course. Since these values are going to be read by javascript we need to use lowercase true and false.
check for some error conditions
The course requires a login but the user is not logged in
The user is logged in, but their “current course” is not this one. Send them to the courses page so they can properly switch courses.
if user and user.course_name != course_name:
user_course_row = await fetch_course(user.course_name)
rslogger.debug(
f"Course mismatch: course name: {user.course_name} does not match requested course: {course_name} redirecting"
)
if user_course_row.base_course == course_name:
return RedirectResponse(
url=f"/ns/books/published/{user.course_name}/{pagepath}"
)
return RedirectResponse(
url=f"/runestone/default/courses?requested_course={course_name}¤t_course={user.course_name}"
)
rslogger.debug(f"Base course = {course_row.base_course}")
chapter = os.path.split(os.path.split(pagepath)[0])[1]
subchapter = os.path.basename(os.path.splitext(pagepath)[0])
if user:
activity_info = await fetch_page_activity_counts(
chapter, subchapter, course_row.base_course, course_name, user.username
)
The template path comes from the base course’s name.
TODO set custom delimiters for PreTeXt books (https://stackoverflow.com/questions/33775085/is-it-possible-to-change-the-default-double-curly-braces-delimiter-in-polymer)
Books built with lots of LaTeX math in them are troublesome as they tend to have many instances
of {{
and }}
which conflicts with the default Jinja2 start stop delimiters. Rather than
escaping all of the latex math the PreTeXt built books use different delimiters for the templates
templates.env is a reference to a Jinja2 Environment object
try - templates.env.block_start_string = “@@@+”
try - templates.env.block_end_string = “@@@-”
if course_attrs.get("markup_system", "RST") == "PreTeXt":
rslogger.debug(f"PRETEXT book found at path {pagepath}")
templates.env.variable_start_string = "~._"
templates.env.variable_end_string = "_.~"
templates.env.comment_start_string = "@@#"
templates.env.comment_end_string = "#@@"
templates.env.globals.update({"URL": URL})
enable compare me can be set per course if its not set provide a default of true
TODO: provide the template google_ga as well as ad servings stuff settings.google_ga
TODO: restore the contributed questions list questions
for books (only fopp) that
show the contributed questions list on an Exercises page.
root_path: The server is mounted in a different location depending on how it’s run (directly from gunicorn/uvicorn or under the /ns
prefix using nginx). Tell the JS what prefix to use for Ajax requests. See also setting root_path and the FastAPI docs. This is then used in the eBookConfig
of runestone/common/project_template/_templates/plugin_layouts/sphinx_bootstrap/layout.html.
new_server_prefix=request.scope.get("root_path"),
user_email=user.email if user else "",
downloads_enabled="true" if course_row.downloads_enabled else "false",
allow_pairs="true" if course_row.allow_pairs else "false",
activity_info=json.dumps(activity_info),
settings=settings,
is_logged_in=logged_in,
subchapter_list=subchapter_list,
serve_ad=serve_ad,
is_instructor="true" if user_is_instructor else "false",
use_services="true" if use_services else "false",
readings=reading_list,
**course_attrs,
)
See templates.
Utilities
This is copied verbatim from https://github.com/pallets/werkzeug/blob/master/werkzeug/security.py#L30.
This is copied verbatim from https://github.com/pallets/werkzeug/blob/master/werkzeug/security.py#L216.
Safely join directory
and one or more untrusted pathnames
. If this
cannot be done, this function returns None
.
- directory
the base directory.
- pathnames
the untrusted pathnames relative to that directory.
parts = [directory]
for filename in pathnames:
if filename != "":
filename = posixpath.normpath(filename)
for sep in _os_alt_seps:
if sep in filename:
return None
if os.path.isabs(filename) or filename == ".." or filename.startswith("../"):
return None
parts.append(filename)
return posixpath.join(*parts)
async def fetch_subchaptoc(course: str, chap: str):
res = await fetch_subchapters(course, chap)
toclist = []
for row in res:
rslogger.debug(f"row = {row}")
sc_url = "{}.html".format(row[0])
title = row[1]
toclist.append(dict(subchap_uri=sc_url, title=title))
return toclist