seitime-frappe/frappe/website/router.py
Suraj Shetty bc2b41d2f5 perf: Simplify dynamic route evaluation to speed up route resolution
validate complete route only if starting of the path matches with the webform route

This avoids the expensive matching from huge route_map (in sites with lots of web forms)
2023-12-08 21:15:34 +05:30

328 lines
9.2 KiB
Python

# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import io
import os
import re
from werkzeug.exceptions import NotFound
from werkzeug.routing import Map, Rule
import frappe
from frappe.website.utils import extract_title, get_frontmatter
def get_page_info_from_web_page_with_dynamic_routes(path):
"""
Query Web Page with dynamic_route = 1 and evaluate if any of the routes match
"""
from frappe.website.doctype.web_page.web_page import get_dynamic_web_pages
rules, page_info = [], {}
for d in get_dynamic_web_pages():
rules.append(Rule(f"/{d.route}", endpoint=d.name))
d.doctype = d.doctype or "Web Page"
page_info[d.name] = d
end_point = evaluate_dynamic_routes(rules, path)
if end_point:
return page_info[end_point]
def get_page_info_from_web_form(path):
"""Query published web forms and evaluate if the route matches"""
from frappe.website.doctype.web_form.web_form import get_published_web_forms
for d in get_published_web_forms():
if not (path.startswith(f"{d.route}") or path.startswith(f"/{d.route}")):
continue
rules = []
rules.append(Rule(f"/{d.route}", endpoint=d.name))
rules.append(Rule(f"/{d.route}/list", endpoint=d.name))
rules.append(Rule(f"/{d.route}/new", endpoint=d.name))
rules.append(Rule(f"/{d.route}/<name>", endpoint=d.name))
rules.append(Rule(f"/{d.route}/<name>/edit", endpoint=d.name))
d.doctype = "Web Form"
end_point = evaluate_dynamic_routes(rules, path)
if end_point:
if path.endswith("/list"):
frappe.form_dict.is_list = True
elif path.endswith("/new"):
frappe.form_dict.is_new = True
elif path.endswith("/edit"):
frappe.form_dict.is_edit = True
else:
frappe.form_dict.is_read = True
return d
def evaluate_dynamic_routes(rules, path):
"""
Use Werkzeug routing to evaluate dynamic routes like /project/<name>
https://werkzeug.palletsprojects.com/en/1.0.x/routing/
"""
route_map = Map(rules)
endpoint = None
if hasattr(frappe.local, "request") and frappe.local.request.environ:
urls = route_map.bind_to_environ(frappe.local.request.environ)
try:
endpoint, args = urls.match("/" + path)
path = endpoint
if args:
# don't cache when there's a query string!
frappe.local.no_cache = 1
frappe.local.form_dict.update(args)
except NotFound:
pass
return endpoint
def get_pages(app=None):
"""Get all pages. Called for docs / sitemap"""
def _build(app):
pages = {}
if app:
apps = [app]
else:
apps = frappe.local.flags.web_pages_apps or frappe.get_installed_apps()
for app in apps:
app_path = frappe.get_app_path(app)
for start in get_start_folders():
pages.update(get_pages_from_path(start, app, app_path))
return pages
return frappe.cache.get_value("website_pages", lambda: _build(app))
def get_pages_from_path(start, app, app_path):
pages = {}
start_path = os.path.join(app_path, start)
if os.path.exists(start_path):
for basepath, folders, files in os.walk(start_path):
# add missing __init__.py
if "__init__.py" not in files and frappe.conf.get("developer_mode"):
open(os.path.join(basepath, "__init__.py"), "a").close()
for fname in files:
fname = frappe.utils.cstr(fname)
if "." not in fname:
continue
page_name, extn = fname.rsplit(".", 1)
if extn in ("js", "css") and os.path.exists(os.path.join(basepath, page_name + ".html")):
# js, css is linked to html, skip
continue
if extn in ("html", "xml", "js", "css", "md"):
page_info = get_page_info(
os.path.join(basepath, fname), app, start, basepath, app_path, fname
)
pages[page_info.route] = page_info
# print frappe.as_json(pages[-1])
return pages
def get_page_info(path, app, start, basepath=None, app_path=None, fname=None):
"""Load page info"""
if fname is None:
fname = os.path.basename(path)
if app_path is None:
app_path = frappe.get_app_path(app)
if basepath is None:
basepath = os.path.dirname(path)
page_name, extn = os.path.splitext(fname)
# add website route
page_info = frappe._dict()
page_info.basename = page_name if extn in ("html", "md") else fname
page_info.basepath = basepath
page_info.page_or_generator = "Page"
page_info.template = os.path.relpath(os.path.join(basepath, fname), app_path)
if page_info.basename == "index":
page_info.basename = ""
# get route from template name
page_info.route = page_info.template.replace(start, "").strip("/")
if os.path.basename(page_info.route) in ("index.html", "index.md"):
page_info.route = os.path.dirname(page_info.route)
# remove the extension
if page_info.route.endswith(".md") or page_info.route.endswith(".html"):
page_info.route = page_info.route.rsplit(".", 1)[0]
page_info.name = page_info.page_name = page_info.route
# controller
page_info.controller_path = os.path.join(basepath, page_name.replace("-", "_") + ".py")
if os.path.exists(page_info.controller_path):
controller = (
app + "." + os.path.relpath(page_info.controller_path, app_path).replace(os.path.sep, ".")[:-3]
)
page_info.controller = controller
# get the source
setup_source(page_info)
if not page_info.title:
page_info.title = extract_title(page_info.source, page_info.route)
# extract properties from controller attributes
load_properties_from_controller(page_info)
return page_info
def setup_source(page_info):
"""Get the HTML source of the template"""
jenv = frappe.get_jenv()
source = jenv.loader.get_source(jenv, page_info.template)[0]
html = ""
if page_info.template.endswith((".md", ".html")):
# extract frontmatter block if exists
try:
# values will be used to update page_info
res = get_frontmatter(source)
if res["attributes"]:
page_info.update(res["attributes"])
source = res["body"]
except Exception:
pass
if page_info.template.endswith(".md"):
source = frappe.utils.md_to_html(source)
page_info.page_toc_html = source.toc_html
if not page_info.show_sidebar:
source = '<div class="from-markdown">' + source + "</div>"
if not page_info.base_template:
page_info.base_template = get_base_template(page_info.route)
if (
page_info.template.endswith(
(
".html",
".md",
)
)
and "{%- extends" not in source
and "{% extends" not in source
):
# set the source only if it contains raw content
html = source
# load css/js files
js_path = os.path.join(page_info.basepath, (page_info.basename or "index") + ".js")
if os.path.exists(js_path) and "{% block script %}" not in html:
with open(js_path, encoding="utf-8") as f:
js = f.read()
page_info.colocated_js = js
css_path = os.path.join(page_info.basepath, (page_info.basename or "index") + ".css")
if os.path.exists(css_path) and "{% block style %}" not in html:
with open(css_path, encoding="utf-8") as f:
css = f.read()
page_info.colocated_css = css
if html:
page_info.source = html
page_info.base_template = page_info.base_template or "templates/web.html"
else:
page_info.source = ""
# show table of contents
setup_index(page_info)
def get_base_template(path=None):
"""
Returns the `base_template` for given `path`.
The default `base_template` for any web route is `templates/web.html` defined in `hooks.py`.
This can be overridden for certain routes in `custom_app/hooks.py` based on regex pattern.
"""
if not path:
path = frappe.local.request.path
base_template_map = frappe.get_hooks("base_template_map") or {}
patterns = list(base_template_map.keys())
patterns_desc = sorted(patterns, key=lambda x: len(x), reverse=True)
for pattern in patterns_desc:
if re.match(pattern, path):
templates = base_template_map[pattern]
return templates[-1]
def setup_index(page_info):
"""Build page sequence from index.txt"""
if page_info.basename == "":
# load index.txt if loading all pages
index_txt_path = os.path.join(page_info.basepath, "index.txt")
if os.path.exists(index_txt_path):
with open(index_txt_path) as f:
page_info.index = f.read().splitlines()
def load_properties_from_controller(page_info):
if not page_info.controller:
return
module = frappe.get_module(page_info.controller)
if not module:
return
for prop in ("base_template_path", "template", "no_cache", "sitemap", "condition_field"):
if hasattr(module, prop):
page_info[prop] = getattr(module, prop)
def get_doctypes_with_web_view():
"""Return doctypes with Has Web View or set via hooks"""
def _get():
installed_apps = frappe.get_installed_apps()
doctypes = frappe.get_hooks("website_generators")
doctypes_with_web_view = frappe.get_all(
"DocType", fields=["name", "module"], filters=dict(has_web_view=1)
)
module_app_map = frappe.local.module_app
doctypes += [
d.name
for d in doctypes_with_web_view
if module_app_map.get(frappe.scrub(d.module)) in installed_apps
]
return doctypes
return frappe.cache.get_value("doctypes_with_web_view", _get)
def get_start_folders():
return frappe.local.flags.web_pages_folders or ("www", "templates/pages")
def clear_routing_cache():
from frappe.website.doctype.web_form.web_form import get_published_web_forms
from frappe.website.doctype.web_page.web_page import get_dynamic_web_pages
from frappe.website.page_renderers.document_page import _find_matching_document_webview
_find_matching_document_webview.clear_cache()
get_dynamic_web_pages.clear_cache()
get_published_web_forms.clear_cache()