Merge branch 'develop' into bump-pydantic-v2

This commit is contained in:
gavin 2023-06-30 22:19:09 +05:30 committed by GitHub
commit 03fbdde007
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
59 changed files with 689 additions and 972 deletions

View file

@ -2213,41 +2213,6 @@ def logger(
)
def log_error(title=None, message=None, reference_doctype=None, reference_name=None):
"""Log error to Error Log"""
# Parameter ALERT:
# the title and message may be swapped
# the better API for this is log_error(title, message), and used in many cases this way
# this hack tries to be smart about whats a title (single line ;-)) and fixes it
traceback = None
if message:
if "\n" in title: # traceback sent as title
traceback, title = title, message
else:
traceback = message
title = title or "Error"
traceback = as_unicode(traceback or get_traceback(with_context=True))
if not db:
print(f"Failed to log error in db: {title}")
return
error_log = get_doc(
doctype="Error Log",
error=traceback,
method=title,
reference_doctype=reference_doctype,
reference_name=reference_name,
)
if flags.read_only:
error_log.deferred_insert()
else:
return error_log.insert(ignore_permissions=True)
def get_desk_link(doctype, name):
html = (
'<a href="/app/Form/{doctype}/{name}" style="font-weight: bold;">{doctype_local} {name}</a>'
@ -2439,6 +2404,8 @@ def validate_and_sanitize_search_inputs(fn):
return wrapper
from frappe.utils.error import log_error # noqa: backward compatibility
if _tune_gc:
# generational GC gets triggered after certain allocs (g0) which is 700 by default.
# This number is quite small for frappe where a single query can potentially create 700+

View file

@ -22,7 +22,7 @@ from frappe import _
from frappe.auth import SAFE_HTTP_METHODS, UNSAFE_HTTP_METHODS, HTTPRequest
from frappe.middlewares import StaticDataMiddleware
from frappe.utils import cint, get_site_name, sanitize_html
from frappe.utils.error import make_error_snapshot
from frappe.utils.error import log_error_snapshot
from frappe.website.serve import get_response
local_manager = LocalManager(frappe.local)
@ -346,7 +346,7 @@ def handle_exception(e):
frappe.local.login_manager.clear_cookies()
if http_status_code >= 500:
make_error_snapshot(e)
log_error_snapshot(e)
if return_as_message:
response = get_response("message", http_status_code=http_status_code)

View file

@ -215,18 +215,39 @@ def start_worker(
)
@click.command("worker-pool")
@click.option(
"--queue",
type=str,
help="Queue to consume from. Multiple queues can be specified using comma-separated string. If not specified all queues are consumed.",
)
@click.option("--num-workers", type=int, default=2, help="Number of workers to spawn in pool.")
@click.option("--quiet", is_flag=True, default=False, help="Hide Log Outputs")
@click.option("--burst", is_flag=True, default=False, help="Run Worker in Burst mode.")
def start_worker_pool(queue, quiet=False, num_workers=2, burst=False):
"""Start a backgrond worker"""
from frappe.utils.background_jobs import start_worker_pool
start_worker_pool(
queue=queue,
quiet=quiet,
burst=burst,
num_workers=num_workers,
)
@click.command("ready-for-migration")
@click.option("--site", help="site name")
@pass_context
def ready_for_migration(context, site=None):
from frappe.utils.doctor import get_pending_jobs
from frappe.utils.doctor import any_job_pending
if not site:
site = get_site(context)
try:
frappe.init(site=site)
pending_jobs = get_pending_jobs(site=site)
pending_jobs = any_job_pending(site=site)
if pending_jobs:
print(f"NOT READY for migration: site {site} has pending background jobs")
@ -251,5 +272,6 @@ commands = [
show_pending_jobs,
start_scheduler,
start_worker,
start_worker_pool,
trigger_scheduler_event,
]

View file

@ -700,9 +700,12 @@ def _use(site, sites_path="."):
def use(site, sites_path="."):
from frappe.installer import update_site_config
if os.path.exists(os.path.join(sites_path, site)):
with open(os.path.join(sites_path, "currentsite.txt"), "w") as sitefile:
sitefile.write(site)
sites_path = os.getcwd()
conifg = os.path.join(sites_path, "common_site_config.json")
update_site_config("default_site", site, validate=False, site_config_path=conifg)
print(f"Current Site set to {site}")
else:
print(f"Site {site} does not exist")

View file

@ -33,13 +33,28 @@ class TestDocShare(FrappeTestCase):
def test_doc_permission(self):
frappe.set_user(self.user)
self.assertFalse(self.event.has_permission())
frappe.set_user("Administrator")
frappe.share.add("Event", self.event.name, self.user)
frappe.set_user(self.user)
self.assertTrue(self.event.has_permission())
# PERF: All share permission check should happen with maximum 1 query.
with self.assertRowsRead(1):
self.assertTrue(self.event.has_permission())
second_event = frappe.get_doc(
{
"doctype": "Event",
"subject": "test share event 2",
"starts_on": "2015-01-01 10:00:00",
"event_type": "Private",
}
).insert()
frappe.share.add("Event", second_event.name, self.user)
with self.assertRowsRead(1):
self.assertTrue(self.event.has_permission())
def test_share_permission(self):
frappe.share.add("Event", self.event.name, self.user, write=1, share=1)

View file

@ -1,12 +0,0 @@
{% if (Object.prototype.toString.call(x) === "[object Object]") { %}
<table class="table">
{% for (var key in x) { %}
<tr>
<td><code>{{ key }}</code></td>
<td>{{ x[key] }}</td>
</tr>
{% } %}
</table>
{% } else { %}
{{ x }}
{% } %}

View file

@ -1,77 +0,0 @@
<style>
a {
cursor: pointer;
}
.codebox {
font-family: monospace;
font-size: 8pt;
}
.codebox .line.current {
background: rgba(0,0,255, 0.1);
}
.codebox .lineno {
text-align: right;
display: inline-block;
width: 30px;
opacity: .5;
}
.codebox .code {
white-space: pre;
}
.object-link {
font-family: monospace;
white-space: pre;
}
</style>
{% function id(){ return id._old_id++; }; id._old_id = 0; %}
<h3>{{ __("Error Report") }}</h3>
<p class="text-muted">{{ doc.pyver }}</p>
<dl>
<dt>{{ __("Timestamp") }}: </dt>
<dd>{{ doc.timestamp }}</dd>
<dt>{{ __("Relapsed") }}</dt>
<dd><code>{{ doc.relapses }}</code></dd>
</dl>
<h3>{{ __("Exception") }}</h3>
{{ frappe.render_template("error_object", {x: JSON.parse(doc.exception)}) }}
<h3>{{ __("Locals") }}</h3>
{{ frappe.render_template("error_object", {x: JSON.parse(doc.locals)} )}}
<h3>{{ __("Traceback") }}</h3>
{% var frames = JSON.parse(doc.frames); %}
{% for (var i in frames) { %}
{% var frameid = id(), frame = frames[i] %}
<p><i class="octicon octicon-file-text"></i> <code>{{ frame.file }}: {{ frame.lnum }}</code>
<div class="row">
<div class="codebox">
<div class="col-lg-11">
{% for (var index in frame.lines) { %}
{% var line = frame.lines[index] %}
<div class="line {{ index == frame.lnum ? "current": "" }}">
<span class="lineno text-muted">{{ index }}</span>
<span class="code">{{ line }}</span>
</div>
{% } %}
</div>
<div class="col-lg-1">
<span class="btn btn-xs btn-default" data-toggle="collapse" data-target="#frame-{{ frameid }}-locals">
<i class="fa fa-list-ul"> {{ __("Locals") }}</i>
</span>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-12 collapse" id="frame-{{ frameid }}-locals">
<h4>{{ __("Locals") }}</h4>
{{ frappe.render_template("error_object", {x: frame.dump }) }}
</div>
</div>
</p>
{% } %}

View file

@ -1,20 +0,0 @@
frappe.ui.form.on("Error Snapshot", "load", function (frm) {
frm.set_read_only(true);
});
frappe.ui.form.on("Error Snapshot", "refresh", function (frm) {
frm.set_df_property(
"view",
"options",
frappe.render_template("error_snapshot", { doc: frm.doc })
);
if (frm.doc.relapses) {
frm.add_custom_button(__("Show Relapses"), function () {
frappe.route_options = {
parent_error_snapshot: frm.doc.name,
};
frappe.set_route("List", "Error Snapshot");
});
}
});

View file

@ -1,130 +0,0 @@
{
"actions": [],
"creation": "2015-11-28 00:57:39.766888",
"doctype": "DocType",
"document_type": "System",
"engine": "InnoDB",
"field_order": [
"view",
"seen",
"evalue",
"timestamp",
"relapses",
"etype",
"traceback",
"parent_error_snapshot",
"pyver",
"exception",
"locals",
"frames"
],
"fields": [
{
"fieldname": "view",
"fieldtype": "HTML",
"label": "Snapshot View"
},
{
"default": "0",
"fieldname": "seen",
"fieldtype": "Check",
"hidden": 1,
"in_filter": 1,
"label": "Seen"
},
{
"fieldname": "evalue",
"fieldtype": "Code",
"hidden": 1,
"in_list_view": 1,
"label": "Friendly Title",
"read_only": 1
},
{
"fieldname": "timestamp",
"fieldtype": "Datetime",
"hidden": 1,
"label": "Timestamp",
"read_only": 1
},
{
"default": "1",
"fieldname": "relapses",
"fieldtype": "Int",
"hidden": 1,
"in_list_view": 1,
"label": "Relapses",
"read_only": 1
},
{
"fieldname": "etype",
"fieldtype": "Data",
"hidden": 1,
"label": "Exception Type",
"read_only": 1
},
{
"fieldname": "traceback",
"fieldtype": "Code",
"hidden": 1,
"label": "Traceback",
"read_only": 1
},
{
"fieldname": "parent_error_snapshot",
"fieldtype": "Data",
"hidden": 1,
"label": "Parent Error Snapshot"
},
{
"fieldname": "pyver",
"fieldtype": "Code",
"hidden": 1,
"label": "Pyver",
"read_only": 1
},
{
"fieldname": "exception",
"fieldtype": "Code",
"hidden": 1,
"label": "Exception"
},
{
"fieldname": "locals",
"fieldtype": "Code",
"hidden": 1,
"label": "Locals"
},
{
"fieldname": "frames",
"fieldtype": "Code",
"hidden": 1,
"label": "Frames"
}
],
"in_create": 1,
"links": [],
"modified": "2022-08-03 12:20:53.504160",
"modified_by": "Administrator",
"module": "Core",
"name": "Error Snapshot",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Administrator",
"share": 1,
"write": 1
}
],
"sort_field": "timestamp",
"sort_order": "DESC",
"states": [],
"title_field": "evalue"
}

View file

@ -1,40 +0,0 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors
# License: MIT. See LICENSE
import frappe
from frappe.model.document import Document
from frappe.query_builder import Interval
from frappe.query_builder.functions import Now
class ErrorSnapshot(Document):
no_feed_on_delete = True
def onload(self):
if not self.parent_error_snapshot:
self.db_set("seen", 1, update_modified=False)
for relapsed in frappe.get_all("Error Snapshot", filters={"parent_error_snapshot": self.name}):
frappe.db.set_value("Error Snapshot", relapsed.name, "seen", 1, update_modified=False)
frappe.local.flags.commit = True
def validate(self):
parent = frappe.get_all(
"Error Snapshot",
filters={"evalue": self.evalue, "parent_error_snapshot": ""},
fields=["name", "relapses", "seen"],
limit_page_length=1,
)
if parent:
parent = parent[0]
self.update({"parent_error_snapshot": parent["name"]})
frappe.db.set_value("Error Snapshot", parent["name"], "relapses", parent["relapses"] + 1)
if parent["seen"]:
frappe.db.set_value("Error Snapshot", parent["name"], "seen", 0)
@staticmethod
def clear_old_logs(days=30):
table = frappe.qb.DocType("Error Snapshot")
frappe.db.delete(table, filters=(table.modified < (Now() - Interval(days=days))))

View file

@ -1,19 +0,0 @@
frappe.listview_settings["Error Snapshot"] = {
add_fields: ["parent_error_snapshot", "relapses", "seen"],
filters: [
["parent_error_snapshot", "=", null],
["seen", "=", false],
],
get_indicator: function (doc) {
if (doc.parent_error_snapshot && doc.parent_error_snapshot.length) {
return [__("Relapsed"), !doc.seen ? "orange" : "blue", "parent_error_snapshot,!=,"];
} else {
return [__("First Level"), !doc.seen ? "red" : "green", "parent_error_snapshot,=,"];
}
},
onload: function (listview) {
frappe.require("logtypes.bundle.js", () => {
frappe.utils.logtypes.show_log_retention_message(cur_list.doctype);
});
},
};

View file

@ -1,11 +0,0 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
from frappe.tests.utils import FrappeTestCase
from frappe.utils.logger import sanitized_dict
# test_records = frappe.get_test_records('Error Snapshot')
class TestErrorSnapshot(FrappeTestCase):
def test_form_dict_sanitization(self):
self.assertNotEqual(sanitized_dict({"pwd": "SECRET", "usr": "WHAT"}).get("pwd"), "SECRET")

View file

@ -174,7 +174,7 @@
"icon": "fa fa-file",
"idx": 1,
"links": [],
"modified": "2022-09-13 15:50:15.508251",
"modified": "2023-05-02 15:42:14.274901",
"modified_by": "Administrator",
"module": "Core",
"name": "File",
@ -196,14 +196,8 @@
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"if_owner": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "All",
"share": 1,
"write": 1
}
],

View file

@ -16,6 +16,7 @@ import frappe
from frappe import _
from frappe.database.schema import SPECIAL_CHAR_PATTERN
from frappe.model.document import Document
from frappe.permissions import get_doctypes_with_read
from frappe.utils import call_hook_method, cint, get_files_path, get_hook_method, get_url
from frappe.utils.file_manager import is_safe_path
from frappe.utils.image import optimize_image, strip_exif_data
@ -718,40 +719,39 @@ def on_doctype_update():
def has_permission(doc, ptype=None, user=None):
has_access = False
user = user or frappe.session.user
if ptype == "create":
has_access = frappe.has_permission("File", "create", user=user)
return frappe.has_permission("File", "create", user=user)
if not doc.is_private or doc.owner in [user, "Guest"] or user == "Administrator":
has_access = True
if not doc.is_private or doc.owner == user or user == "Administrator":
return True
if doc.attached_to_doctype and doc.attached_to_name:
attached_to_doctype = doc.attached_to_doctype
attached_to_name = doc.attached_to_name
try:
ref_doc = frappe.get_doc(attached_to_doctype, attached_to_name)
ref_doc = frappe.get_doc(attached_to_doctype, attached_to_name)
if ptype in ["write", "create", "delete"]:
has_access = ref_doc.has_permission("write")
if ptype in ["write", "create", "delete"]:
return ref_doc.has_permission("write")
else:
return ref_doc.has_permission("read")
if ptype == "delete" and not has_access:
frappe.throw(
_(
"Cannot delete file as it belongs to {0} {1} for which you do not have permissions"
).format(doc.attached_to_doctype, doc.attached_to_name),
frappe.PermissionError,
)
else:
has_access = ref_doc.has_permission("read")
except frappe.DoesNotExistError:
# if parent doc is not created before file is created
# we cannot check its permission so we will use file's permission
pass
return False
return has_access
def get_permission_query_conditions(user: str = None) -> str:
user = user or frappe.session.user
if user == "Administrator":
return ""
readable_doctypes = ", ".join(repr(dt) for dt in get_doctypes_with_read())
return f"""
(`tabFile`.`is_private` = 0)
OR (`tabFile`.`attached_to_doctype` IS NULL AND `tabFile`.`owner` = {user !r})
OR (`tabFile`.`attached_to_doctype` IN ({readable_doctypes}))
"""
# Note: kept at the end to not cause circular, partial imports & maintain backwards compatibility

View file

@ -611,46 +611,42 @@ class TestAttachmentsAccess(FrappeTestCase):
def setUp(self) -> None:
frappe.db.delete("File", {"is_folder": 0})
def test_attachments_access(self):
def test_list_private_attachments(self):
frappe.set_user("test4@example.com")
self.attached_to_doctype, self.attached_to_docname = make_test_doc()
frappe.get_doc(
{
"doctype": "File",
"file_name": "test_user.txt",
"attached_to_doctype": self.attached_to_doctype,
"attached_to_name": self.attached_to_docname,
"content": "Testing User",
}
frappe.new_doc(
"File",
file_name="test_user_attachment.txt",
attached_to_doctype=self.attached_to_doctype,
attached_to_name=self.attached_to_docname,
content="Testing User",
is_private=1,
).insert()
frappe.get_doc(
{
"doctype": "File",
"file_name": "test_user_home.txt",
"content": "User Home",
}
frappe.new_doc(
"File",
file_name="test_user_standalone.txt",
content="User Home",
is_private=1,
).insert()
frappe.set_user("test@example.com")
frappe.get_doc(
{
"doctype": "File",
"file_name": "test_system_manager.txt",
"attached_to_doctype": self.attached_to_doctype,
"attached_to_name": self.attached_to_docname,
"content": "Testing System Manager",
}
frappe.new_doc(
"File",
file_name="test_sm_attachment.txt",
attached_to_doctype=self.attached_to_doctype,
attached_to_name=self.attached_to_docname,
content="Testing System Manager",
is_private=1,
).insert()
frappe.get_doc(
{
"doctype": "File",
"file_name": "test_sm_home.txt",
"content": "System Manager Home",
}
frappe.new_doc(
"File",
file_name="test_sm_standalone.txt",
content="System Manager Home",
is_private=1,
).insert()
system_manager_files = [file.file_name for file in get_files_in_folder("Home")["files"]]
@ -664,15 +660,47 @@ class TestAttachmentsAccess(FrappeTestCase):
file.file_name for file in get_files_in_folder("Home/Attachments")["files"]
]
self.assertIn("test_sm_home.txt", system_manager_files)
self.assertNotIn("test_sm_home.txt", user_files)
self.assertIn("test_user_home.txt", system_manager_files)
self.assertIn("test_user_home.txt", user_files)
self.assertIn("test_sm_standalone.txt", system_manager_files)
self.assertNotIn("test_sm_standalone.txt", user_files)
self.assertIn("test_system_manager.txt", system_manager_attachments_files)
self.assertNotIn("test_system_manager.txt", user_attachments_files)
self.assertIn("test_user.txt", system_manager_attachments_files)
self.assertIn("test_user.txt", user_attachments_files)
self.assertIn("test_user_standalone.txt", user_files)
self.assertNotIn("test_user_standalone.txt", system_manager_files)
self.assertIn("test_sm_attachment.txt", system_manager_attachments_files)
self.assertIn("test_sm_attachment.txt", user_attachments_files)
self.assertIn("test_user_attachment.txt", system_manager_attachments_files)
self.assertIn("test_user_attachment.txt", user_attachments_files)
def test_list_public_single_file(self):
"""Ensure that users are able to list public standalone files."""
frappe.set_user("test@example.com")
frappe.new_doc(
"File",
file_name="test_public_single.txt",
content="Public single File",
is_private=0,
).insert()
frappe.set_user("test4@example.com")
files = [file.file_name for file in get_files_in_folder("Home")["files"]]
self.assertIn("test_public_single.txt", files)
def test_list_public_attachment(self):
"""Ensure that users are able to list public attachments."""
frappe.set_user("test@example.com")
self.attached_to_doctype, self.attached_to_docname = make_test_doc()
frappe.new_doc(
"File",
file_name="test_public_attachment.txt",
attached_to_doctype=self.attached_to_doctype,
attached_to_name=self.attached_to_docname,
content="Public Attachment",
is_private=0,
).insert()
frappe.set_user("test4@example.com")
files = [file.file_name for file in get_files_in_folder("Home/Attachments")["files"]]
self.assertIn("test_public_attachment.txt", files)
def tearDown(self) -> None:
frappe.set_user("Administrator")

View file

@ -14,7 +14,6 @@ DEFAULT_LOGTYPES_RETENTION = {
"Error Log": 30,
"Activity Log": 90,
"Email Queue": 30,
"Error Snapshot": 30,
"Scheduled Job Log": 90,
"Route History": 90,
"Submission Queue": 30,
@ -45,11 +44,11 @@ def _supports_log_clearing(doctype: str) -> bool:
class LogSettings(Document):
def validate(self):
self._remove_unsupported_doctypes()
self.remove_unsupported_doctypes()
self._deduplicate_entries()
self.add_default_logtypes()
def _remove_unsupported_doctypes(self):
def remove_unsupported_doctypes(self):
for entry in list(self.logs_to_clear):
if _supports_log_clearing(entry.ref_doctype):
continue
@ -114,6 +113,7 @@ class LogSettings(Document):
def run_log_clean_up():
doc = frappe.get_doc("Log Settings")
doc.remove_unsupported_doctypes()
doc.add_default_logtypes()
doc.save()
doc.clear_logs()
@ -156,7 +156,6 @@ LOG_DOCTYPES = [
"Route History",
"Email Queue",
"Email Queue Recipient",
"Error Snapshot",
"Error Log",
]

View file

@ -62,7 +62,6 @@ class TestLogSettings(FrappeTestCase):
"Activity Log",
"Email Queue",
"Route History",
"Error Snapshot",
"Scheduled Job Log",
]

View file

@ -135,7 +135,7 @@ class Report(Document):
# automatically set as prepared
execution_time = (datetime.datetime.now() - start_time).total_seconds()
if execution_time > threshold and not self.prepared_report:
self.db_set("prepared_report", 1)
frappe.enqueue(enable_prepared_report, report=self.name)
frappe.cache.hset("report_execution_time", self.name, execution_time)
@ -382,3 +382,7 @@ def get_group_by_column_label(args, meta):
function=sql_fn_map[args.aggregate_function], fieldlabel=aggregate_on_label
)
return label
def enable_prepared_report(report: str):
frappe.db.set_value("Report", report, "prepared_report", 1)

View file

@ -63,23 +63,15 @@ class RQJob(Document):
order_desc = "desc" in args.get("order_by", "")
matched_job_ids = RQJob.get_matching_job_ids(args)
matched_job_ids = RQJob.get_matching_job_ids(args)[start : start + page_length]
jobs = []
for job_ids in create_batch(matched_job_ids, 100):
jobs.extend(
serialize_job(job)
for job in Job.fetch_many(job_ids=job_ids, connection=get_redis_conn())
if job and for_current_site(job)
)
if len(jobs) > start + page_length:
# we have fetched enough. This is inefficient but because of site filtering TINA
break
conn = get_redis_conn()
jobs = [serialize_job(job) for job in Job.fetch_many(job_ids=matched_job_ids, connection=conn)]
return sorted(jobs, key=lambda j: j.modified, reverse=order_desc)[start : start + page_length]
return sorted(jobs, key=lambda j: j.modified, reverse=order_desc)
@staticmethod
def get_matching_job_ids(args):
def get_matching_job_ids(args) -> list[str]:
filters = make_filter_dict(args.get("filters"))
queues = _eval_filters(filters.get("queue"), QUEUES)
@ -92,7 +84,7 @@ class RQJob(Document):
for status in statuses:
matched_job_ids.extend(fetch_job_ids(queue, status))
return matched_job_ids
return filter_current_site_jobs(matched_job_ids)
@check_permissions
def delete(self):
@ -107,8 +99,7 @@ class RQJob(Document):
@staticmethod
def get_count(args) -> int:
# Can not be implemented efficiently due to site filtering hence ignored.
return 0
return len(RQJob.get_matching_job_ids(args))
# None of these methods apply to virtual job doctype, overriden for sanity.
@staticmethod
@ -155,6 +146,12 @@ def for_current_site(job: Job) -> bool:
return job.kwargs.get("site") == frappe.local.site
def filter_current_site_jobs(job_ids: list[str]) -> list[str]:
site = frappe.local.site
return [j for j in job_ids if j.startswith(site)]
def _eval_filters(filter, values: list[str]) -> list[str]:
if filter:
operator, operand = filter
@ -186,10 +183,13 @@ def remove_failed_jobs():
frappe.only_for("System Manager")
for queue in get_queues():
fail_registry = queue.failed_job_registry
for job_ids in create_batch(fail_registry.get_job_ids(), 100):
for job in Job.fetch_many(job_ids=job_ids, connection=get_redis_conn()):
if job and for_current_site(job):
fail_registry.remove(job, delete_job=True)
failed_jobs = filter_current_site_jobs(fail_registry.get_job_ids())
# Delete in batches to avoid loading too many things in memory
conn = get_redis_conn()
for job_ids in create_batch(failed_jobs, 100):
for job in Job.fetch_many(job_ids=job_ids, connection=conn):
job and fail_registry.remove(job, delete_job=True)
def get_all_queued_jobs():

View file

@ -15,7 +15,6 @@ frappe.listview_settings["RQ Job"] = {
);
if (listview.list_view_settings) {
listview.list_view_settings.disable_count = 1;
listview.list_view_settings.disable_sidebar_stats = 1;
}
@ -57,6 +56,6 @@ frappe.listview_settings["RQ Job"] = {
}
listview.refresh();
}, 5000);
}, 15000);
},
};

View file

@ -97,13 +97,38 @@ class TestRQJob(FrappeTestCase):
self.assertIn("quitting", cstr(stderr))
@timeout(20)
def test_job_id_dedup(self):
def test_multi_queue_burst_consumption_worker_pool(self):
for _ in range(3):
for q in ["default", "short"]:
frappe.enqueue(self.BG_JOB, sleep=1, queue=q)
_, stderr = execute_in_shell(
"bench worker-pool --queue short,default --burst --num-workers=4", check_exit_code=True
)
self.assertIn("quitting", cstr(stderr))
@timeout(20)
def test_job_id_manual_dedup(self):
job_id = "test_dedup"
job = frappe.enqueue(self.BG_JOB, sleep=5, job_id=job_id)
self.assertTrue(is_job_enqueued(job_id))
self.check_status(job, "finished")
self.assertFalse(is_job_enqueued(job_id))
@timeout(20)
def test_auto_job_dedup(self):
job_id = "test_dedup"
job1 = frappe.enqueue(self.BG_JOB, sleep=2, job_id=job_id, deduplicate=True)
job2 = frappe.enqueue(self.BG_JOB, sleep=5, job_id=job_id, deduplicate=True)
self.assertIsNone(job2)
self.check_status(job1, "finished") # wait
# Failed jobs last longer, subsequent job should still pass with same ID.
job3 = frappe.enqueue(self.BG_JOB, fail=True, job_id=job_id, deduplicate=True)
self.check_status(job3, "failed")
job4 = frappe.enqueue(self.BG_JOB, sleep=1, job_id=job_id, deduplicate=True)
self.check_status(job4, "finished")
@timeout(20)
def test_enqueue_after_commit(self):
job_id = frappe.generate_hash()

View file

@ -0,0 +1,38 @@
frappe.listview_settings["Server Script"] = {
onload: function (listview) {
add_github_star_cta(listview);
},
};
function add_github_star_cta(listview) {
try {
const key = "show_github_star_banner";
if (localStorage.getItem(key) == "false") {
return;
}
if (listview.github_star_banner) {
listview.github_star_banner.remove();
}
const message = "Loving Frappe Framework?";
const link = "https://github.com/frappe/frappe";
const cta = "Star us on GitHub";
listview.github_star_banner = $(`
<div style="position: relative;">
<div class="pr-3">
${message} <br><a href="${link}" target="_blank" style="color: var(--primary-color)">${cta} &rarr; </a>
</div>
<div style="position: absolute; top: -1px; right: -4px; cursor: pointer;" title="Dismiss"
onclick="localStorage.setItem('${key}', 'false') || this.parentElement.remove()">
<svg class="icon icon-sm" style="">
<use class="" href="#icon-close"></use>
</svg>
</div>
</div>
`).appendTo(listview.page.sidebar);
} catch (error) {
console.error(error);
}
}

View file

@ -11,7 +11,6 @@ def get_notification_config():
"Communication": {"status": "Open", "communication_type": "Communication"},
"ToDo": "frappe.core.notifications.get_things_todo",
"Event": "frappe.core.notifications.get_todays_events",
"Error Snapshot": {"seen": 0, "parent_error_snapshot": None},
"Workflow Action": {"status": "Open"},
},
}

View file

@ -155,74 +155,6 @@
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "System Logs",
"link_count": 6,
"onboard": 0,
"type": "Card Break"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Background Jobs",
"link_count": 0,
"link_to": "RQ Job",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Scheduled Jobs Logs",
"link_count": 0,
"link_to": "Scheduled Job Log",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Error Logs",
"link_count": 0,
"link_to": "Error Log",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Error Snapshot",
"link_count": 0,
"link_to": "Error Snapshot",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Communication Logs",
"link_count": 0,
"link_to": "Communication",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Activity Log",
"link_count": 0,
"link_to": "Activity Log",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
@ -331,9 +263,67 @@
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "System Logs",
"link_count": 5,
"onboard": 0,
"type": "Card Break"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Background Jobs",
"link_count": 0,
"link_to": "RQ Job",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Scheduled Jobs Logs",
"link_count": 0,
"link_to": "Scheduled Job Log",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Error Logs",
"link_count": 0,
"link_to": "Error Log",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Communication Logs",
"link_count": 0,
"link_to": "Communication",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Activity Log",
"link_count": 0,
"link_to": "Activity Log",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
}
],
"modified": "2023-05-24 14:47:24.395259",
"modified": "2023-06-28 10:30:17.228167",
"modified_by": "Administrator",
"module": "Core",
"name": "Build",

View file

@ -132,7 +132,6 @@ def return_unsubscribed_page(email, doctype, name):
def flush(from_test=False):
"""flush email queue, every time: called from scheduler"""
from frappe.email.doctype.email_queue.email_queue import send_mail
from frappe.utils.background_jobs import get_jobs
# To avoid running jobs inside unit tests
if frappe.are_emails_muted():
@ -142,24 +141,16 @@ def flush(from_test=False):
if cint(frappe.db.get_default("suspend_email_queue")) == 1:
return
try:
queued_jobs = set(get_jobs(site=frappe.local.site, key="job_name")[frappe.local.site])
except Exception:
queued_jobs = set()
for row in get_queue():
try:
job_name = f"email_queue_sendmail_{row.name}"
if job_name not in queued_jobs:
frappe.enqueue(
method=send_mail,
email_queue_name=row.name,
now=from_test,
job_name=job_name,
queue="short",
)
else:
frappe.logger().debug(f"Not queueing job {job_name} because it is in queue already")
frappe.enqueue(
method=send_mail,
email_queue_name=row.name,
now=from_test,
job_id=f"email_queue_sendmail_{row.name}",
queue="short",
dedupicate=True,
)
except Exception:
frappe.get_doc("Email Queue", row.name).log_error()

View file

@ -108,6 +108,7 @@ permission_query_conditions = {
"Communication": "frappe.core.doctype.communication.communication.get_permission_query_conditions_for_communication",
"Workflow Action": "frappe.workflow.doctype.workflow_action.workflow_action.get_permission_query_conditions",
"Prepared Report": "frappe.core.doctype.prepared_report.prepared_report.get_permission_query_condition",
"File": "frappe.core.doctype.file.file.get_permission_query_conditions",
}
has_permission = {
@ -213,7 +214,6 @@ scheduler_events = {
"hourly": [
"frappe.model.utils.link_count.update_link_count",
"frappe.model.utils.user_settings.sync_user_settings",
"frappe.utils.error.collect_error_snapshots",
"frappe.desk.page.backups.backups.delete_downloadable_backups",
"frappe.deferred_insert.save_to_db",
"frappe.desk.form.document_follow.send_hourly_updates",

View file

@ -617,7 +617,6 @@ def make_site_dirs():
os.path.join("public", "files"),
os.path.join("private", "backups"),
os.path.join("private", "files"),
"error-snapshots",
"locks",
"logs",
]:

View file

@ -26,6 +26,7 @@ class Webhook(Document):
self.validate_request_url()
self.validate_request_body()
self.validate_repeating_fields()
self.validate_secret()
self.preview_document = None
def on_update(self):
@ -74,6 +75,13 @@ class Webhook(Document):
if len(webhook_data) != len(set(webhook_data)):
frappe.throw(_("Same Field is entered more than once"))
def validate_secret(self):
if self.enable_security:
try:
self.get_password("webhook_secret", False).encode("utf8")
except Exception:
frappe.throw(_("Invalid Webhook Secret"))
@frappe.whitelist()
def generate_preview(self):
# This function doesn't need to do anything specific as virtual fields
@ -112,16 +120,21 @@ def get_context(doc):
def enqueue_webhook(doc, webhook) -> None:
webhook: Webhook = frappe.get_doc("Webhook", webhook.get("name"))
headers = get_webhook_headers(doc, webhook)
data = get_webhook_data(doc, webhook)
try:
webhook: Webhook = frappe.get_doc("Webhook", webhook.get("name"))
headers = get_webhook_headers(doc, webhook)
data = get_webhook_data(doc, webhook)
if webhook.is_dynamic_url:
request_url = frappe.render_template(webhook.request_url, get_context(doc))
else:
request_url = webhook.request_url
if webhook.is_dynamic_url:
request_url = frappe.render_template(webhook.request_url, get_context(doc))
else:
request_url = webhook.request_url
except Exception as e:
frappe.logger().debug({"enqueue_webhook_error": e})
log_request(webhook.name, doc.name, request_url, headers, data)
return
r = None
for i in range(3):
try:
r = requests.request(

View file

@ -31,7 +31,6 @@ execute:frappe.reload_doc('core', 'doctype', 'user') #2017-10-27
execute:frappe.reload_doc('core', 'doctype', 'report_column')
execute:frappe.reload_doc('core', 'doctype', 'report_filter')
execute:frappe.reload_doc('core', 'doctype', 'report') #2020-08-25
execute:frappe.reload_doc('core', 'doctype', 'error_snapshot')
execute:frappe.get_doc("User", "Guest").save()
execute:frappe.delete_doc("DocType", "Control Panel", force=1)
execute:frappe.delete_doc("DocType", "Tag")
@ -42,7 +41,6 @@ execute:frappe.db.sql("delete from `tabProperty Setter` where `property` = 'idx'
execute:frappe.db.sql("delete from tabSessions where user is null")
execute:frappe.delete_doc("DocType", "Backup Manager")
execute:frappe.permissions.reset_perms("Web Page")
execute:frappe.permissions.reset_perms("Error Snapshot")
execute:frappe.db.sql("delete from `tabWeb Page` where ifnull(template_path, '')!=''")
execute:frappe.core.doctype.language.language.update_language_names() # 2017-04-12
execute:frappe.db.set_value("Print Settings", "Print Settings", "add_draft_heading", 1)
@ -227,3 +225,4 @@ frappe.patches.v15_0.remove_background_jobs_from_dropdown
frappe.desk.doctype.form_tour.patches.introduce_ui_tours
execute:frappe.delete_doc_if_exists("Workspace", "Customization")
execute:frappe.db.set_single_value("Document Naming Settings", "default_amend_naming", "Amend Counter")
execute:frappe.delete_doc_if_exists("DocType", "Error Snapshot")

View file

@ -15,7 +15,6 @@ def execute():
"Email Queue": get_current_setting("clear_email_queue_after") or 30,
# child table on email queue
"Email Queue Recipient": get_current_setting("clear_email_queue_after") or 30,
"Error Snapshot": get_current_setting("clear_error_log_after") or 90,
# newly added
"Scheduled Job Log": 90,
}

View file

@ -118,17 +118,24 @@ def has_permission(
def false_if_not_shared():
if ptype in ("read", "write", "share", "submit", "email", "print"):
shared = frappe.share.get_shared(
doctype, user, ["read" if ptype in ("email", "print") else ptype]
)
rights = ["read" if ptype in ("email", "print") else ptype]
if doc:
doc_name = get_doc_name(doc)
if doc_name in shared:
shared = frappe.share.get_shared(
doctype,
user,
rights=rights,
filters=[["share_name", "=", doc_name]],
limit=1,
)
if shared:
if ptype in ("read", "write", "share", "submit") or meta.permissions[0].get(ptype):
return True
elif shared:
elif frappe.share.get_shared(doctype, user, rights=rights, limit=1):
# if atleast one shared doc of that type, then return True
# this is used in db_query to check if permission on DocType
return True

View file

@ -174,6 +174,15 @@ frappe.ui.form.ControlAutocomplete = class ControlAutoComplete extends frappe.ui
if (typeof options[0] === "string") {
options = options.map((o) => ({ label: o, value: o }));
}
options = options.map((o) => {
if (typeof o !== "string") {
o.label = o.label.toString();
o.value = o.value.toString();
}
return o;
});
return options;
}

View file

@ -711,7 +711,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
}
get_column_html(col, doc) {
if (col.type === "Status") {
if (col.type === "Status" || col.df?.options == "Workflow State") {
return `
<div class="list-row-col hidden-xs ellipsis">
${this.get_indicator_html(doc)}

View file

@ -202,14 +202,16 @@ frappe.dashboard_utils = {
},
get_all_filters(doc) {
let filters = JSON.parse(doc.filters_json || "null");
let dynamic_filters = JSON.parse(doc.dynamic_filters_json || "null");
let filters = doc.filters_json ? JSON.parse(doc.filters_json) : null;
let dynamic_filters = doc.dynamic_filters_json
? JSON.parse(doc.dynamic_filters_json)
: null;
if (!dynamic_filters) {
if (!dynamic_filters || !Object.keys(dynamic_filters).length) {
return filters;
}
if ($.isArray(dynamic_filters)) {
if (Array.isArray(dynamic_filters)) {
dynamic_filters.forEach((f) => {
try {
f[3] = eval(f[3]);

View file

@ -45,42 +45,5 @@
$background-color: var(--bg-color),
$border-radius: var(--border-radius),
) {
@if $content {
img:after {
content: url($content);
}
} @else {
img:after {
content: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='lightgrey' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-image'><rect x='3' y='3' width='18' height='18' rx='2' ry='2'/><circle cx='8.5' cy='8.5' r='1.5'/><polyline points='21 15 16 10 5 21'/></svg>");
}
}
img[alt]:after {
height: $height;
top: $top;
left: $left;
background-color: $background-color;
border-radius: $border-radius;
width: 100%;
position: absolute;
@include flex();
z-index: 1;
}
}
// @mixin img-foreground() {
// content: "\f1c5";
// display: block;
// font-style: normal;
// font-family: FontAwesome;
// font-size: 32px;
// color: var(--text-muted);
// position: absolute;
// top: 50%;
// transform: translateY(-50%);
// left: 0;
// width: 100%;
// text-align: center;
// }
// Deprecated: Does not work as expected anymore. Also, this never worked in Safari.
}

View file

@ -97,11 +97,6 @@
color: transparent;
position: relative;
}
@include broken-img(
$height: 70px,
$top: -15px,
);
}
}

View file

@ -153,11 +153,6 @@
position: relative;
width: 100%;
}
@include broken-img(
$height: 175px,
$border-radius: 0
);
}
.image-title {

View file

@ -181,11 +181,6 @@
color: transparent;
position: relative;
}
@include broken-img(
$height: 125px,
$top: -4px,
)
}
.kanban-card-body {

View file

@ -100,11 +100,11 @@
.group-by-button {
margin: 5px;
max-width: 125px;
}
.group-by-button.btn-primary-light {
color: var(--text-on-blue);
outline: 1px solid var(--bg-dark-blue);
}
.group-by-icon.active {

View file

@ -100,10 +100,10 @@ def emit_via_redis(event, message, room):
:param event: Event name, like `task_progress` etc.
:param message: JSON message object. For async must contain `task_id`
:param room: name of the room"""
from frappe.utils.background_jobs import get_redis_conn
from frappe.utils.background_jobs import get_redis_connection_without_auth
with suppress(redis.exceptions.ConnectionError):
r = get_redis_conn()
r = get_redis_connection_without_auth()
r.publish("events", frappe.as_json({"event": event, "message": message, "room": room}))

View file

@ -18,10 +18,9 @@ import frappe.translate
import frappe.utils
from frappe import _
from frappe.cache_manager import clear_user_cache
from frappe.query_builder import DocType, Order
from frappe.query_builder.functions import Now
from frappe.query_builder.utils import PseudoColumn
from frappe.query_builder import Order
from frappe.utils import cint, cstr, get_assets_json
from frappe.utils.data import add_to_date
@frappe.whitelist()
@ -62,7 +61,7 @@ def get_sessions_to_clear(user=None, keep_current=False):
simultaneous_sessions = frappe.db.get_value("User", user, "simultaneous_sessions") or 1
offset = simultaneous_sessions - 1
session = DocType("Sessions")
session = frappe.qb.DocType("Sessions")
session_id = frappe.qb.from_(session).where(session.user == user)
if keep_current:
session_id = session_id.where(session.sid != frappe.session.sid)
@ -88,7 +87,7 @@ def delete_session(sid=None, user=None, reason="Session Expired"):
frappe.cache.hdel("session", sid)
frappe.cache.hdel("last_db_session_update", sid)
if sid and not user:
table = DocType("Sessions")
table = frappe.qb.DocType("Sessions")
user_details = (
frappe.qb.from_(table).where(table.sid == sid).select(table.user).run(as_dict=True)
)
@ -112,17 +111,12 @@ def clear_all_sessions(reason=None):
def get_expired_sessions():
"""Returns list of expired sessions"""
sessions = DocType("Sessions")
return frappe.db.get_values(
sessions,
filters=(
PseudoColumn(f"({Now()} - {sessions.lastupdate.get_sql()})") > get_expiry_period_for_query()
),
fieldname="sid",
order_by=None,
pluck=True,
)
sessions = frappe.qb.DocType("Sessions")
return (
frappe.qb.from_(sessions)
.select(sessions.sid)
.where(sessions.lastupdate < get_expired_threshold())
).run(pluck=True)
def clear_expired_sessions():
@ -232,7 +226,7 @@ class Session:
sid = frappe.generate_hash()
self.data.user = self.user
self.data.sid = sid
self.sid = self.data.sid = sid
self.data.data.user = self.user
self.data.data.session_ip = frappe.local.request_ip
if self.user != "Guest":
@ -268,14 +262,17 @@ class Session:
frappe.db.commit()
def insert_session_record(self):
frappe.db.sql(
"""insert into `tabSessions`
(`sessiondata`, `user`, `lastupdate`, `sid`, `status`)
values (%s , %s, NOW(), %s, 'Active')""",
(str(self.data["data"]), self.data["user"], self.data["sid"]),
)
# also add to memcache
Sessions = frappe.qb.DocType("Sessions")
now = frappe.utils.now()
(
frappe.qb.into(Sessions)
.columns(
Sessions.sessiondata, Sessions.user, Sessions.lastupdate, Sessions.sid, Sessions.status
)
.insert((str(self.data["data"]), self.data["user"], now, self.data["sid"], "Active"))
).run()
frappe.cache.hset("session", self.data.sid, self.data)
def resume(self):
@ -338,20 +335,18 @@ class Session:
return data and data.data
def get_session_data_from_db(self):
sessions = DocType("Sessions")
rec = frappe.db.get_values(
sessions,
filters=(sessions.sid == self.sid)
& (
PseudoColumn(f"({Now()} - {sessions.lastupdate.get_sql()})") < get_expiry_period_for_query()
),
fieldname=["user", "sessiondata"],
order_by=None,
)
sessions = frappe.qb.DocType("Sessions")
if rec:
data = frappe._dict(frappe.safe_eval(rec and rec[0][1] or "{}"))
data.user = rec[0][0]
record = (
frappe.qb.from_(sessions)
.select(sessions.user, sessions.sessiondata)
.where(sessions.sid == self.sid)
.where(sessions.lastupdate > get_expired_threshold())
).run()
if record:
data = frappe._dict(frappe.safe_eval(record and record[0][1] or "{}"))
data.user = record[0][0]
else:
self._delete_session()
data = None
@ -373,6 +368,8 @@ class Session:
now = frappe.utils.now()
Sessions = frappe.qb.DocType("Sessions")
self.data["data"]["last_updated"] = now
self.data["data"]["lang"] = str(frappe.lang)
@ -384,17 +381,14 @@ class Session:
updated_in_db = False
if (force or (time_diff is None) or (time_diff > 600)) and not frappe.flags.read_only:
# update sessions table
frappe.db.sql(
"""update `tabSessions` set sessiondata=%s,
lastupdate=NOW() where sid=%s""",
(str(self.data["data"]), self.data["sid"]),
)
(
frappe.qb.update(Sessions)
.where(Sessions.sid == self.data["sid"])
.set(Sessions.sessiondata, str(self.data["data"]))
.set(Sessions.lastupdate, now)
).run()
# update last active in user table
frappe.db.sql(
"""update `tabUser` set last_active=%(now)s where name=%(name)s""",
{"now": now, "name": frappe.session.user},
)
frappe.db.set_value("User", frappe.session.user, "last_active", now, update_modified=False)
frappe.db.commit()
frappe.cache.hset("last_db_session_update", self.sid, now)
@ -421,6 +415,15 @@ def get_expiry_in_seconds(expiry=None):
return (cint(parts[0]) * 3600) + (cint(parts[1]) * 60) + cint(parts[2])
def get_expired_threshold():
"""Get cutoff time before which all sessions are considered expired."""
now = frappe.utils.now()
expiry_in_seconds = get_expiry_in_seconds()
return add_to_date(now, seconds=-expiry_in_seconds, as_string=True)
def get_expiry_period():
exp_sec = frappe.defaults.get_global_default("session_expiry") or "06:00:00"

View file

@ -141,7 +141,7 @@ def get_users(doctype, name):
)
def get_shared(doctype, user=None, rights=None):
def get_shared(doctype, user=None, rights=None, *, filters=None, limit=None):
"""Get list of shared document names for given user and DocType.
:param doctype: DocType of which shared names are queried.
@ -154,14 +154,22 @@ def get_shared(doctype, user=None, rights=None):
if not rights:
rights = ["read"]
filters = [[right, "=", 1] for right in rights]
filters += [["share_doctype", "=", doctype]]
share_filters = [[right, "=", 1] for right in rights]
share_filters += [["share_doctype", "=", doctype]]
if filters:
share_filters += filters
or_filters = [["user", "=", user]]
if user != "Guest":
or_filters += [["everyone", "=", 1]]
shared_docs = frappe.get_all(
"DocShare", fields=["share_name"], filters=filters, or_filters=or_filters, order_by=None
"DocShare",
fields=["share_name"],
filters=share_filters,
or_filters=or_filters,
order_by=None,
limit_page_length=limit,
)
return [doc.share_name for doc in shared_docs]

View file

@ -1,14 +1,18 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import time
from unittest.mock import patch
import requests
import frappe
import frappe.utils
from frappe.auth import LoginAttemptTracker
from frappe.frappeclient import AuthError, FrappeClient
from frappe.sessions import Session, get_expired_sessions, get_expiry_in_seconds
from frappe.tests.test_api import FrappeAPITestCase
from frappe.tests.utils import FrappeTestCase
from frappe.utils import get_site_url, now
from frappe.utils.data import add_to_date
from frappe.www.login import _generate_temporary_login_link
@ -26,9 +30,7 @@ class TestAuth(FrappeTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.HOST_NAME = frappe.get_site_config().host_name or frappe.utils.get_site_url(
frappe.local.site
)
cls.HOST_NAME = frappe.get_site_config().host_name or get_site_url(frappe.local.site)
cls.test_user_email = "test_auth@test.com"
cls.test_user_name = "test_auth_user"
cls.test_user_mobile = "+911234567890"
@ -197,3 +199,27 @@ class TestLoginAttemptTracker(FrappeTestCase):
tracker.add_failure_attempt()
self.assertTrue(tracker.is_user_allowed())
class TestSessionExpirty(FrappeAPITestCase):
def test_session_expires(self):
sid = self.sid # triggers login for test case login
s: Session = frappe.local.session_obj
expiry_in = get_expiry_in_seconds()
session_created = now()
# Try with 1% increments of times, it should always work
for step in range(0, 100, 1):
seconds_elapsed = expiry_in * step / 100
time_now = add_to_date(session_created, seconds=seconds_elapsed, as_string=True)
with patch("frappe.utils.now", return_value=time_now):
data = s.get_session_data_from_db()
self.assertEqual(data.user, "Administrator")
# 1% higher should immediately expire
time_now = add_to_date(session_created, seconds=expiry_in * 1.01, as_string=True)
with patch("frappe.utils.now", return_value=time_now):
self.assertIn(sid, get_expired_sessions())
self.assertFalse(s.get_session_data_from_db())

View file

@ -10,6 +10,7 @@ from frappe.tests.utils import FrappeTestCase
from frappe.utils.background_jobs import (
RQ_JOB_FAILURE_TTL,
RQ_RESULTS_TTL,
create_job_id,
execute_job,
generate_qname,
get_redis_conn,
@ -54,11 +55,12 @@ class TestBackgroundJobs(FrappeTestCase):
def test_enqueue_call(self):
with patch.object(Queue, "enqueue_call") as mock_enqueue_call:
frappe.enqueue(
job = frappe.enqueue(
"frappe.handler.ping",
queue="short",
timeout=300,
kwargs={"site": frappe.local.site},
job_id="test",
)
mock_enqueue_call.assert_called_once_with(
@ -78,7 +80,7 @@ class TestBackgroundJobs(FrappeTestCase):
at_front=False,
failure_ttl=RQ_JOB_FAILURE_TTL,
result_ttl=RQ_RESULTS_TTL,
job_id=None,
job_id=create_job_id("test"),
)
def test_job_hooks(self):

View file

@ -20,9 +20,11 @@ from unittest.mock import patch
import click
from click import Command
from click.testing import CliRunner, Result
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
# imports - module imports
import frappe
import frappe.commands.scheduler
import frappe.commands.site
import frappe.commands.utils
import frappe.recorder
@ -760,6 +762,19 @@ class TestBenchBuild(BaseTestCommands):
)
class TestSchedulerUtils(BaseTestCommands):
# Retry just in case there are stuck queued jobs
@retry(
retry=retry_if_exception_type(AssertionError),
stop=stop_after_attempt(3),
wait=wait_fixed(3),
reraise=True,
)
def test_ready_for_migrate(self):
with cli(frappe.commands.scheduler.ready_for_migration) as result:
self.assertEqual(result.exit_code, 0)
class TestCommandUtils(FrappeTestCase):
def test_bench_helper(self):
from frappe.utils.bench_helper import get_app_groups

View file

@ -140,27 +140,6 @@ class TestDB(FrappeTestCase):
frappe.db.get_value("DocType", "DocField", order_by="creation desc, modified asc, name", run=0),
)
def test_get_value_limits(self):
# check both dict and list style filters
filters = [{"enabled": 1}, [["enabled", "=", 1]]]
for filter in filters:
self.assertEqual(1, len(frappe.db.get_values("User", filters=filter, limit=1)))
# count of last touched rows as per DB-API 2.0 https://peps.python.org/pep-0249/#rowcount
self.assertGreaterEqual(1, cint(frappe.db._cursor.rowcount))
self.assertEqual(2, len(frappe.db.get_values("User", filters=filter, limit=2)))
self.assertGreaterEqual(2, cint(frappe.db._cursor.rowcount))
# without limits length == count
self.assertEqual(
len(frappe.db.get_values("User", filters=filter)), frappe.db.count("User", filter)
)
frappe.db.get_value("User", filters=filter)
self.assertGreaterEqual(1, cint(frappe.db._cursor.rowcount))
frappe.db.exists("User", filter)
self.assertGreaterEqual(1, cint(frappe.db._cursor.rowcount))
def test_escape(self):
frappe.db.escape("香港濟生堂製藥有限公司 - IT".encode())

View file

@ -27,6 +27,7 @@ from frappe.model.base_document import get_controller
from frappe.query_builder.utils import db_type_is
from frappe.tests.test_query_builder import run_only_if
from frappe.tests.utils import FrappeTestCase
from frappe.utils import cint
from frappe.website.path_resolver import PathResolver
@ -70,6 +71,25 @@ class TestPerformance(FrappeTestCase):
with self.assertQueryCount(0):
get_controller("User")
def test_get_value_limits(self):
# check both dict and list style filters
filters = [{"enabled": 1}, [["enabled", "=", 1]]]
for filter in filters:
with self.assertRowsRead(1):
self.assertEqual(1, len(frappe.db.get_values("User", filters=filter, limit=1)))
with self.assertRowsRead(2):
self.assertEqual(2, len(frappe.db.get_values("User", filters=filter, limit=2)))
self.assertEqual(
len(frappe.db.get_values("User", filters=filter)), frappe.db.count("User", filter)
)
with self.assertRowsRead(1):
frappe.db.get_value("User", filters=filter)
with self.assertRowsRead(1):
frappe.db.exists("User", filter)
def test_db_value_cache(self):
"""Link validation if repeated should just use db.value_cache, hence no extra queries"""
doc = frappe.get_last_doc("User")

View file

@ -11,10 +11,16 @@ from frappe.website.utils import build_response, clear_website_cache, get_home_p
class TestWebsite(FrappeTestCase):
def setUp(self):
frappe.set_user("Guest")
self._clearRequest()
def tearDown(self):
frappe.db.delete("Access Log")
frappe.set_user("Administrator")
self._clearRequest()
def _clearRequest(self):
if hasattr(frappe.local, "request"):
delattr(frappe.local, "request")
def test_home_page(self):
frappe.set_user("Administrator")
@ -340,8 +346,9 @@ class TestWebsite(FrappeTestCase):
FILES_TO_SKIP = choices(list(WWW.glob("**/*.py*")), k=10)
for suffix in FILES_TO_SKIP:
content = get_response_content(suffix.relative_to(WWW))
self.assertIn("404", content)
path: str = suffix.relative_to(WWW).as_posix()
content = get_response_content(path)
self.assertIn("<title>Not Found</title>", content)
def test_metatags(self):
content = get_response_content("/_test/_test_metatags")

View file

@ -92,6 +92,26 @@ class FrappeTestCase(unittest.TestCase):
finally:
frappe.db.sql = orig_sql
@contextmanager
def assertRowsRead(self, count):
rows_read = 0
def _sql_with_count(*args, **kwargs):
nonlocal rows_read
ret = orig_sql(*args, **kwargs)
# count of last touched rows as per DB-API 2.0 https://peps.python.org/pep-0249/#rowcount
rows_read += cint(frappe.db._cursor.rowcount)
return ret
try:
orig_sql = frappe.db.sql
frappe.db.sql = _sql_with_count
yield
self.assertLessEqual(rows_read, count, msg="Queries read more rows than expected")
finally:
frappe.db.sql = orig_sql
class MockedRequestTestCase(FrappeTestCase):
def setUp(self):

View file

@ -4,16 +4,17 @@ import socket
import time
from collections import defaultdict
from functools import lru_cache
from typing import Any, Callable, Literal, NoReturn
from typing import Any, Callable, NoReturn
from uuid import uuid4
import redis
from redis.exceptions import BusyLoadingError, ConnectionError
from rq import Connection, Queue, Worker
from rq import Queue, Worker
from rq.exceptions import NoSuchJobError
from rq.job import Job, JobStatus
from rq.logutils import setup_loghandlers
from rq.worker import RandomWorker, RoundRobinWorker
from rq.worker import DequeueStrategy
from rq.worker_pool import WorkerPool
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
import frappe
@ -65,6 +66,7 @@ def enqueue(
on_failure: Callable = None,
at_front: bool = False,
job_id: str = None,
deduplicate=False,
**kwargs,
) -> Job | Any:
"""
@ -78,14 +80,28 @@ def enqueue(
:param job_name: [DEPRECATED] can be used to name an enqueue call, which can be used to prevent duplicate calls
:param now: if now=True, the method is executed via frappe.call
:param kwargs: keyword arguments to be passed to the method
:param deduplicate: do not re-queue job if it's already queued, requires job_id.
:param job_id: Assigning unique job id, which can be checked using `is_job_enqueued`
"""
# To handle older implementations
is_async = kwargs.pop("async", is_async)
if job_id:
# namespace job ids to sites
job_id = create_job_id(job_id)
if deduplicate:
if not job_id:
frappe.throw(_("`job_id` paramater is required for deduplication."))
job = get_job(job_id)
if job and job.get_status() in (JobStatus.QUEUED, JobStatus.STARTED):
frappe.logger().debug(f"Not queueing job {job.id} because it is in queue already")
return
elif job:
# delete job to avoid argument issues related to job args
# https://github.com/rq/rq/issues/793
job.delete()
# If job exists and is completed then delete it before re-queue
# namespace job ids to sites
job_id = create_job_id(job_id)
if job_name:
deprecation_warning("Using enqueue with `job_name` is deprecated, use `job_id` instead.")
@ -230,14 +246,14 @@ def start_worker(
rq_username: str | None = None,
rq_password: str | None = None,
burst: bool = False,
strategy: Literal["round_robin", "random"] | None = None,
strategy: DequeueStrategy | None = DequeueStrategy.DEFAULT,
) -> NoReturn | None: # pragma: no cover
"""Wrapper to start rq worker. Connects to redis and monitors these queues."""
DEQUEUE_STRATEGIES = {"round_robin": RoundRobinWorker, "random": RandomWorker}
if frappe._tune_gc:
gc.collect()
gc.freeze()
if not strategy:
strategy = DequeueStrategy.DEFAULT
_freeze_gc()
with frappe.init_site():
# empty init is required to get redis_queue from common_site_config.json
@ -251,19 +267,59 @@ def start_worker(
if os.environ.get("CI"):
setup_loghandlers("ERROR")
WorkerKlass = DEQUEUE_STRATEGIES.get(strategy, Worker)
logging_level = "INFO"
if quiet:
logging_level = "WARNING"
with Connection(redis_connection):
logging_level = "INFO"
if quiet:
logging_level = "WARNING"
worker = WorkerKlass(queues, name=get_worker_name(queue_name))
worker.work(
logging_level=logging_level,
burst=burst,
date_format="%Y-%m-%d %H:%M:%S",
log_format="%(asctime)s,%(msecs)03d %(message)s",
)
worker = Worker(queues, name=get_worker_name(queue_name), connection=redis_connection)
worker.work(
logging_level=logging_level,
burst=burst,
date_format="%Y-%m-%d %H:%M:%S",
log_format="%(asctime)s,%(msecs)03d %(message)s",
dequeue_strategy=strategy,
)
def start_worker_pool(
queue: str | None = None,
num_workers: int = 1,
quiet: bool = False,
burst: bool = False,
) -> NoReturn:
"""Start worker pool with specified number of workers.
WARNING: This feature is considered "EXPERIMENTAL".
"""
_freeze_gc()
with frappe.init_site():
redis_connection = get_redis_conn()
if queue:
queue = [q.strip() for q in queue.split(",")]
queues = get_queue_list(queue, build_queue_name=True)
if os.environ.get("CI"):
setup_loghandlers("ERROR")
logging_level = "INFO"
if quiet:
logging_level = "WARNING"
pool = WorkerPool(
queues=queues,
connection=redis_connection,
num_workers=num_workers,
)
pool.start(logging_level=logging_level, burst=burst)
def _freeze_gc():
if frappe._tune_gc:
gc.collect()
gc.freeze()
def get_worker_name(queue):
@ -353,8 +409,8 @@ def validate_queue(queue, default_queue_list=None):
@retry(
retry=retry_if_exception_type(BusyLoadingError) | retry_if_exception_type(ConnectionError),
stop=stop_after_attempt(10),
retry=retry_if_exception_type((BusyLoadingError, ConnectionError)),
stop=stop_after_attempt(5),
wait=wait_fixed(1),
reraise=True,
)
@ -382,9 +438,7 @@ def get_redis_conn(username=None, password=None):
try:
if not cred:
if not _redis_queue_conn:
_redis_queue_conn = RedisQueue.get_connection()
return _redis_queue_conn
return get_redis_connection_without_auth()
else:
return RedisQueue.get_connection(**cred)
except (redis.exceptions.AuthenticationError, redis.exceptions.ResponseError):
@ -399,6 +453,14 @@ def get_redis_conn(username=None, password=None):
raise
def get_redis_connection_without_auth():
global _redis_queue_conn
if not _redis_queue_conn:
_redis_queue_conn = RedisQueue.get_connection()
return _redis_queue_conn
def get_queues() -> list[Queue]:
"""Get all the queues linked to the current bench."""
queues = Queue.all(connection=get_redis_conn())
@ -434,6 +496,9 @@ def test_job(s):
def create_job_id(job_id: str) -> str:
"""Generate unique job id for deduplication"""
if not job_id:
job_id = str(uuid4())
return f"{frappe.local.site}::{job_id}"
@ -443,9 +508,13 @@ def is_job_enqueued(job_id: str) -> bool:
def get_job_status(job_id: str) -> JobStatus | None:
"""Get RQ job status, returns None if job is not found."""
job = get_job(job_id)
if job:
return job.get_status()
def get_job(job_id: str) -> Job:
try:
job = Job.fetch(create_job_id(job_id), connection=get_redis_conn())
return Job.fetch(create_job_id(job_id), connection=get_redis_conn())
except NoSuchJobError:
return None
return job.get_status()

View file

@ -4,6 +4,7 @@ import os
import traceback
import warnings
from pathlib import Path
from textwrap import dedent
import click
@ -52,10 +53,19 @@ def get_sites(site_arg: str) -> list[str]:
return [site_arg]
elif os.environ.get("FRAPPE_SITE"):
return [os.environ.get("FRAPPE_SITE")]
elif os.path.exists("currentsite.txt"):
with open("currentsite.txt") as f:
if site := f.read().strip():
return [site]
elif default_site := frappe.get_conf().default_site:
return [default_site]
# This is not supported, just added here for warning.
elif (site := frappe.read_file("currentsite.txt")) and site.strip():
click.secho(
dedent(
f"""
WARNING: currentsite.txt is not supported anymore for setting default site. Use following command to set it as default site.
$ bench use {site}"""
),
fg="red",
)
return []

View file

@ -593,7 +593,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: 16
node-version: 18
check-latest: true
- name: Cache pip

View file

@ -79,6 +79,15 @@ def get_pending_jobs(site=None):
return jobs_per_queue
def any_job_pending(site: str) -> bool:
for queue in get_queue_list():
q = get_queue(queue)
for job_id in q.get_job_ids():
if job_id.startswith(site):
return True
return False
def check_number_of_workers():
return len(get_workers())

View file

@ -1,17 +1,10 @@
# Copyright (c) 2015, Maxwell Morais and contributors
# License: MIT. See LICENSE
import datetime
import functools
import inspect
import json
import linecache
import os
import sys
import traceback
import frappe
from frappe.utils import cstr, encode
EXCLUDE_EXCEPTIONS = (
frappe.AuthenticationError,
@ -37,194 +30,57 @@ def _is_ldap_exception(e):
return False
def make_error_snapshot(exception):
if frappe.conf.disable_error_snapshot:
def log_error(
title=None, message=None, reference_doctype=None, reference_name=None, *, defer_insert=False
):
"""Log error to Error Log"""
# Parameter ALERT:
# the title and message may be swapped
# the better API for this is log_error(title, message), and used in many cases this way
# this hack tries to be smart about whats a title (single line ;-)) and fixes it
traceback = None
if message:
if "\n" in title: # traceback sent as title
traceback, title = title, message
else:
traceback = message
title = title or "Error"
traceback = frappe.as_unicode(traceback or frappe.get_traceback(with_context=True))
if not frappe.db:
print(f"Failed to log error in db: {title}")
return
error_log = frappe.get_doc(
doctype="Error Log",
error=traceback,
method=title,
reference_doctype=reference_doctype,
reference_name=reference_name,
)
if frappe.flags.read_only or defer_insert:
error_log.deferred_insert()
else:
return error_log.insert(ignore_permissions=True)
def log_error_snapshot(exception: Exception):
if isinstance(exception, EXCLUDE_EXCEPTIONS) or _is_ldap_exception(exception):
return
logger = frappe.logger(with_more_info=True)
try:
error_id = "{timestamp:s}-{ip:s}-{hash:s}".format(
timestamp=cstr(datetime.datetime.now()),
ip=frappe.local.request_ip or "127.0.0.1",
hash=frappe.generate_hash(length=3),
)
snapshot_folder = get_error_snapshot_path()
frappe.create_folder(snapshot_folder)
snapshot_file_path = os.path.join(snapshot_folder, f"{error_id}.json")
snapshot = get_snapshot(exception)
with open(encode(snapshot_file_path), "wb") as error_file:
error_file.write(encode(frappe.as_json(snapshot)))
logger.error(f"New Exception collected with id: {error_id}")
log_error(title=str(exception), defer_insert=True)
logger.error("New Exception collected in error log")
except Exception as e:
logger.error(f"Could not take error snapshot: {e}", exc_info=True)
def get_snapshot(exception, context=10):
import pydoc
"""
Return a dict describing a given traceback (based on cgitb.text)
"""
etype, evalue, etb = sys.exc_info()
if isinstance(etype, type):
etype = etype.__name__
# creates a snapshot dict with some basic information
s = {
"pyver": "Python {version:s}: {executable:s} (prefix: {prefix:s})".format(
version=sys.version.split(maxsplit=1)[0], executable=sys.executable, prefix=sys.prefix
),
"timestamp": cstr(datetime.datetime.now()),
"traceback": traceback.format_exc(),
"frames": [],
"etype": cstr(etype),
"evalue": cstr(repr(evalue)),
"exception": {},
"locals": {},
}
# start to process frames
records = inspect.getinnerframes(etb, 5)
for frame, file, lnum, func, lines, index in records:
file = file and os.path.abspath(file) or "?"
args, varargs, varkw, locals = inspect.getargvalues(frame)
call = ""
if func != "?":
call = inspect.formatargvalues(
args, varargs, varkw, locals, formatvalue=lambda value: f"={pydoc.text.repr(value)}"
)
# basic frame information
f = {"file": file, "func": func, "call": call, "lines": {}, "lnum": lnum}
def reader(lnum=[lnum]): # noqa
try:
# B023: function is evaluated immediately, binding not necessary
return linecache.getline(file, lnum[0]) # noqa: B023
finally:
lnum[0] += 1
vars = _scanvars(reader, frame, locals)
# if it is a view, replace with generated code
# if file.endswith('html'):
# lmin = lnum > context and (lnum - context) or 0
# lmax = lnum + context
# lines = code.split("\n")[lmin:lmax]
# index = min(context, lnum) - 1
if index is not None:
i = lnum - index
for line in lines:
f["lines"][i] = line.rstrip()
i += 1
# dump local variable (referenced in current line only)
f["dump"] = {}
for name, where, value in vars:
if name in f["dump"]:
continue
if value is not __UNDEF__:
if where == "global":
name = f"global {name:s}"
elif where != "local":
name = where + " " + name.split(".")[-1]
f["dump"][name] = pydoc.text.repr(value)
else:
f["dump"][name] = "undefined"
s["frames"].append(f)
# add exception type, value and attributes
if isinstance(evalue, BaseException):
for name in dir(evalue):
if name != "messages" and not name.startswith("__"):
value = pydoc.text.repr(getattr(evalue, name))
s["exception"][name] = encode(value)
# add all local values (of last frame) to the snapshot
for name, value in locals.items():
s["locals"][name] = value if isinstance(value, str) else pydoc.text.repr(value)
return s
def collect_error_snapshots():
"""Scheduled task to collect error snapshots from files and push into Error Snapshot table"""
if frappe.conf.disable_error_snapshot:
return
try:
path = get_error_snapshot_path()
if not os.path.exists(path):
return
for fname in os.listdir(path):
fullpath = os.path.join(path, fname)
try:
with open(fullpath) as filedata:
data = json.load(filedata)
except ValueError:
# empty file
os.remove(fullpath)
continue
for field in ["locals", "exception", "frames"]:
data[field] = frappe.as_json(data[field])
doc = frappe.new_doc("Error Snapshot")
doc.update(data)
doc.save()
frappe.db.commit()
os.remove(fullpath)
clear_old_snapshots()
except Exception as e:
make_error_snapshot(e)
# prevent creation of unlimited error snapshots
raise
def clear_old_snapshots():
"""Clear snapshots that are older than a month"""
from frappe.query_builder import DocType, Interval
from frappe.query_builder.functions import Now
ErrorSnapshot = DocType("Error Snapshot")
frappe.db.delete(ErrorSnapshot, filters=(ErrorSnapshot.creation < (Now() - Interval(months=1))))
path = get_error_snapshot_path()
today = datetime.datetime.now()
for file in os.listdir(path):
p = os.path.join(path, file)
ctime = datetime.datetime.fromtimestamp(os.path.getctime(p))
if (today - ctime).days > 31:
os.remove(os.path.join(path, p))
def get_error_snapshot_path():
return frappe.get_site_path("error-snapshots")
def get_default_args(func):
"""Get default arguments of a function from its signature."""
signature = inspect.signature(func)
@ -270,56 +126,3 @@ def raise_error_on_no_output(error_message, error_type=None, keep_quiet=None):
return wrapper_raise_error_on_no_output
return decorator_raise_error_on_no_output
# Vendored from cgitb standard library reused under PSF License:
# https://github.com/python/cpython/blob/main/LICENSE
import keyword
import tokenize
__UNDEF__ = [] # a special sentinel object
def _scanvars(reader, frame, locals):
"""Scan one logical line of Python and look up values of variables used."""
vars, lasttoken, parent, prefix, value = [], None, None, "", __UNDEF__
for ttype, token, start, end, line in tokenize.generate_tokens(reader):
if ttype == tokenize.NEWLINE:
break
if ttype == tokenize.NAME and token not in keyword.kwlist:
if lasttoken == ".":
if parent is not __UNDEF__:
value = getattr(parent, token, __UNDEF__)
vars.append((prefix + token, prefix, value))
else:
where, value = _lookup(token, frame, locals)
vars.append((token, where, value))
elif token == ".":
prefix += lasttoken + "."
parent = value
else:
parent, prefix = None, ""
lasttoken = token
return vars
def _lookup(name, frame, locals):
"""Find the value for a given name in the given environment."""
if name in locals:
return "local", locals[name]
if name in frame.f_globals:
return "global", frame.f_globals[name]
if "__builtins__" in frame.f_globals:
builtins = frame.f_globals["__builtins__"]
if type(builtins) is type({}): # noqa
if name in builtins:
return "builtin", builtins[name]
else:
if hasattr(builtins, name):
return "builtin", getattr(builtins, name)
return None, __UNDEF__
# end: vendored code

View file

@ -631,7 +631,7 @@ def get_link_options(web_form_name, doctype, allow_read_on_all_link_options=Fals
if title_field and show_title_field_in_link:
return json.dumps(link_options, default=str)
else:
return "\n".join([doc.value for doc in link_options])
return "\n".join([str(doc.value) for doc in link_options])
else:
raise frappe.PermissionError(

View file

@ -8,7 +8,18 @@ import frappe
from frappe.website.page_renderers.base_renderer import BaseRenderer
from frappe.website.utils import is_binary_file
UNSUPPORTED_STATIC_PAGE_TYPES = ("html", "md", "js", "xml", "css", "txt", "py", "json")
UNSUPPORTED_STATIC_PAGE_TYPES = (
"css",
"html",
"js",
"json",
"md",
"py",
"pyc",
"pyo",
"txt",
"xml",
)
class StaticPage(BaseRenderer):

View file

@ -51,7 +51,6 @@ class PathResolver:
TemplatePage,
ListPage,
PrintPage,
NotFoundPage,
]
for renderer in renderers:

View file

@ -31,10 +31,6 @@ function get_conf() {
if (process.env.FRAPPE_SITE) {
conf.default_site = process.env.FRAPPE_SITE;
}
if (fs.existsSync("sites/currentsite.txt")) {
conf.default_site = fs.readFileSync("sites/currentsite.txt").toString().trim();
}
return conf;
}