seitime-frappe/frappe/website/page_renderers/template_page.py
Ankush Menat eb8da5fd59
fix!: Correct resolution order for template page (#20927)
Current resolution is confusing mess:

1. Evaluation is done in order of installed app, first install wins
2. Except frappe, frappe is treated as lowest priority.

Following same principle of "last write wins" everywhere similar to
previous PRs:

- https://github.com/frappe/frappe/pull/17869
- https://github.com/frappe/frappe/pull/20648
- https://github.com/frappe/frappe/pull/19653

Closes https://github.com/frappe/frappe/issues/20377
2023-05-07 13:19:23 +05:30

315 lines
9.5 KiB
Python

import os
from importlib.machinery import all_suffixes
import click
import frappe
from frappe.website.page_renderers.base_template_page import BaseTemplatePage
from frappe.website.router import get_base_template, get_page_info
from frappe.website.utils import (
cache_html,
extract_comment_tag,
extract_title,
get_frontmatter,
get_next_link,
get_sidebar_items,
get_toc,
is_binary_file,
)
PY_LOADER_SUFFIXES = tuple(all_suffixes())
WEBPAGE_PY_MODULE_PROPERTIES = (
"base_template_path",
"template",
"no_cache",
"sitemap",
"condition_field",
)
COMMENT_PROPERTY_KEY_VALUE_MAP = {
"no-breadcrumbs": ("no_breadcrumbs", 1),
"show-sidebar": ("show_sidebar", 1),
"add-breadcrumbs": ("add_breadcrumbs", 1),
"no-header": ("no_header", 1),
"add-next-prev-links": ("add_next_prev_links", 1),
"no-cache": ("no_cache", 1),
"no-sitemap": ("sitemap", 0),
"sitemap": ("sitemap", 1),
}
class TemplatePage(BaseTemplatePage):
def __init__(self, path, http_status_code=None):
super().__init__(path=path, http_status_code=http_status_code)
self.set_template_path()
def set_template_path(self):
"""
Searches for file matching the path in the /www
and /templates/pages folders and sets path if match is found
"""
folders = get_start_folders()
for app in reversed(frappe.get_installed_apps()):
app_path = frappe.get_app_path(app)
for dirname in folders:
search_path = os.path.join(app_path, dirname, self.path)
for file_path in self.get_index_path_options(search_path):
if os.path.isfile(file_path) and not is_binary_file(file_path):
self.app = app
self.app_path = app_path
self.file_dir = dirname
self.basename = os.path.splitext(file_path)[0]
self.template_path = os.path.relpath(file_path, self.app_path)
self.basepath = os.path.dirname(file_path)
self.filename = os.path.basename(file_path)
self.name = os.path.splitext(self.filename)[0]
return
def can_render(self):
return (
hasattr(self, "template_path")
and self.template_path
and not self.template_path.endswith(PY_LOADER_SUFFIXES)
)
@staticmethod
def get_index_path_options(search_path):
return (
frappe.as_unicode(f"{search_path}{d}") for d in ("", ".html", ".md", "/index.html", "/index.md")
)
def render(self):
html = self.get_html()
html = self.add_csrf_token(html)
return self.build_response(html)
@cache_html
def get_html(self):
# context object should be separate from self for security
# because it will be accessed via the user defined template
self.init_context()
self.set_pymodule()
self.update_context()
self.setup_template_source()
self.load_colocated_files()
self.set_properties_from_source()
self.post_process_context()
html = self.render_template()
html = self.update_toc(html)
return html
def post_process_context(self):
self.set_user_info()
self.add_sidebar_and_breadcrumbs()
super().post_process_context()
def add_sidebar_and_breadcrumbs(self):
if self.basepath:
self.context.sidebar_items = get_sidebar_items(self.context.website_sidebar, self.basepath)
if self.context.add_breadcrumbs and not self.context.parents:
parent_path = os.path.dirname(self.path)
if self.path.endswith("index"):
# in case of index page move one directory up for parent path
parent_path = os.path.dirname(parent_path)
for parent_file_path in self.get_index_path_options(parent_path):
parent_file_path = os.path.join(self.app_path, self.file_dir, parent_file_path)
if os.path.isfile(parent_file_path):
parent_page_context = get_page_info(parent_file_path, self.app, self.file_dir)
if parent_page_context:
self.context.parents = [
dict(route=os.path.dirname(self.path), title=parent_page_context.title)
]
break
def set_pymodule(self):
"""
A template may have a python module with a `get_context` method along with it in the
same folder. Also the hyphens will be coverted to underscore for python module names.
This method sets the pymodule_name if it exists.
"""
template_basepath = os.path.splitext(self.template_path)[0]
self.pymodule_name = None
# replace - with _ in the internal modules names
self.pymodule_path = os.path.join(
os.path.dirname(template_basepath),
os.path.basename(template_basepath.replace("-", "_")) + ".py",
)
if os.path.exists(os.path.join(self.app_path, self.pymodule_path)):
self.pymodule_name = self.app + "." + self.pymodule_path.replace(os.path.sep, ".")[:-3]
def setup_template_source(self):
"""Setup template source, frontmatter and markdown conversion"""
self.source = self.get_raw_template()
self.extract_frontmatter()
self.convert_from_markdown()
def update_context(self):
self.set_page_properties()
self.context.build_version = frappe.utils.get_build_version()
if self.pymodule_name:
self.pymodule = frappe.get_module(self.pymodule_name)
self.set_pymodule_properties()
data = self.run_pymodule_method("get_context")
# some methods may return a "context" object
if data:
self.context.update(data)
# TODO: self.context.children = self.run_pymodule_method('get_children')
self.context.developer_mode = frappe.conf.developer_mode
if self.context.http_status_code:
self.http_status_code = self.context.http_status_code
def set_pymodule_properties(self):
for prop in WEBPAGE_PY_MODULE_PROPERTIES:
if hasattr(self.pymodule, prop):
self.context[prop] = getattr(self.pymodule, prop)
def set_page_properties(self):
self.context.base_template = self.context.base_template or get_base_template(self.path)
self.context.basepath = self.basepath
self.context.basename = self.basename
self.context.name = self.name
self.context.path = self.path
self.context.route = self.path
self.context.template = self.template_path
def set_properties_from_source(self):
if not self.source:
return
context = self.context
if not context.title:
context.title = extract_title(self.source, self.path)
base_template = extract_comment_tag(self.source, "base_template")
if base_template:
context.base_template = base_template
if (
context.base_template
and "{%- extends" not in self.source
and "{% extends" not in self.source
and "</body>" not in self.source
):
self.source = """{{% extends "{0}" %}}
{{% block page_content %}}{1}{{% endblock %}}""".format(
context.base_template, self.source
)
self.set_properties_via_comments()
def set_properties_via_comments(self):
for comment, (context_key, value) in COMMENT_PROPERTY_KEY_VALUE_MAP.items():
comment_tag = f"<!-- {comment} -->"
if comment_tag in self.source:
self.context[context_key] = value
click.echo(f"\n⚠️ DEPRECATION WARNING: {comment_tag} will be deprecated on 2021-12-31.")
click.echo(f"Please remove it from {self.template_path} in {self.app}")
def run_pymodule_method(self, method_name):
if hasattr(self.pymodule, method_name):
import inspect
method = getattr(self.pymodule, method_name)
if inspect.getfullargspec(method).args:
return method(self.context)
else:
return method()
def render_template(self):
if self.template_path.endswith("min.js"):
html = self.source # static
else:
if self.context.safe_render is not None:
safe_render = self.context.safe_render
else:
safe_render = True
html = frappe.render_template(self.source, self.context, safe_render=safe_render)
return html
def extends_template(self):
return self.template_path.endswith((".html", ".md")) and (
"{%- extends" in self.source or "{% extends" in self.source
)
def get_raw_template(self):
return frappe.get_jloader().get_source(frappe.get_jenv(), self.context.template)[0]
def load_colocated_files(self):
"""load co-located css/js files with the same name"""
js_path = self.basename + ".js"
if os.path.exists(js_path) and "{% block script %}" not in self.source:
self.context.colocated_js = self.get_colocated_file(js_path)
css_path = self.basename + ".css"
if os.path.exists(css_path) and "{% block style %}" not in self.source:
self.context.colocated_css = self.get_colocated_file(css_path)
def get_colocated_file(self, path):
with open(path, encoding="utf-8") as f:
return f.read()
def extract_frontmatter(self):
if not self.template_path.endswith((".md", ".html")):
return
try:
# values will be used to update self
res = get_frontmatter(self.source)
if res["attributes"]:
self.context.update(res["attributes"])
self.source = res["body"]
except Exception:
pass
def convert_from_markdown(self):
if self.template_path.endswith(".md"):
self.source = frappe.utils.md_to_html(self.source)
self.context.page_toc_html = self.source.toc_html
if not self.context.show_sidebar:
self.source = '<div class="from-markdown">' + self.source + "</div>"
def update_toc(self, html):
if "{index}" in html:
html = html.replace("{index}", get_toc(self.path))
if "{next}" in html:
html = html.replace("{next}", get_next_link(self.path))
return html
def set_standard_path(self, path):
self.app = "frappe"
self.app_path = frappe.get_app_path("frappe")
self.path = path
self.template_path = f"www/{path}.html"
def set_missing_values(self):
super().set_missing_values()
# for backward compatibility
self.context.docs_base_url = "/docs"
def set_user_info(self):
from frappe.utils.user import get_fullname_and_avatar
info = get_fullname_and_avatar(frappe.session.user)
self.context["fullname"] = info.fullname
self.context["user_image"] = info.avatar
self.context["user"] = info.name
def get_start_folders():
return frappe.local.flags.web_pages_folders or ("www", "templates/pages")