From 96099513a1b857a354062dccfa966aa987a3d1c3 Mon Sep 17 00:00:00 2001 From: Ali <010109ali@gmail.com> Date: Mon, 13 Apr 2026 11:54:48 +0000 Subject: [PATCH] access control: hide Quality and Subcontracting modules Adds jey_theme.access_control with a single source of truth (BLOCKED_MODULES, BLOCKED_DOCTYPES_EXTRA) and three Frappe hooks: - before_request: HTTP-level block on /api/resource/, /app/ and any whitelisted method passing `doctype` in form_dict. Runs before the Administrator bypass in frappe.permissions, so it blocks every user. - extend_bootinfo: strips blocked modules/doctypes/workspaces from bootinfo (workspaces.pages, module_wise_workspaces, app_data, desktop_icons, user.can_read/...) so the client router never registers routes for them and the sidebar/switcher hide them. - has_permission wildcard: per-doc safety net for non-Administrator users. shared.css gets desktop-icon[data-id] fallbacks for Quality/Subcontracting. Reversal: clear BLOCKED_MODULES in access_control.py and run `bench --site clear-cache && bench restart`. No DB writes are made. Co-Authored-By: Claude Opus 4.6 (1M context) --- jey_theme/access_control.py | 240 ++++++++++++++++++++++++++++++++ jey_theme/hooks.py | 10 ++ jey_theme/public/css/shared.css | 13 ++ 3 files changed, 263 insertions(+) create mode 100644 jey_theme/access_control.py diff --git a/jey_theme/access_control.py b/jey_theme/access_control.py new file mode 100644 index 0000000..7cb9fd7 --- /dev/null +++ b/jey_theme/access_control.py @@ -0,0 +1,240 @@ +from urllib.parse import unquote + +import frappe +from frappe import _ + +BLOCKED_MODULES: frozenset[str] = frozenset({ + "Quality Management", + "Subcontracting", +}) + +BLOCKED_DOCTYPES_EXTRA: frozenset[str] = frozenset({ + "Quality Inspection", + "Quality Inspection Template", + "Quality Inspection Parameter", + "Quality Inspection Reading", +}) + + +def _slugify(name: str) -> str: + return name.lower().replace(" ", "-") + + +def get_blocked_doctypes() -> frozenset[str]: + cached = getattr(frappe.local, "_jey_blocked_doctypes", None) + if cached is not None: + return cached + doctypes = set(BLOCKED_DOCTYPES_EXTRA) + if BLOCKED_MODULES: + try: + rows = frappe.get_all( + "DocType", + filters={"module": ("in", list(BLOCKED_MODULES))}, + pluck="name", + ) + doctypes.update(rows) + except Exception: + pass + result = frozenset(doctypes) + frappe.local._jey_blocked_doctypes = result + return result + + +def get_blocked_workspace_names() -> frozenset[str]: + cached = getattr(frappe.local, "_jey_blocked_workspaces", None) + if cached is not None: + return cached + names: set[str] = set() + try: + rows = frappe.get_all( + "Workspace", + filters={"module": ("in", list(BLOCKED_MODULES))}, + pluck="name", + ) + names.update(rows) + except Exception: + pass + result = frozenset(names) + frappe.local._jey_blocked_workspaces = result + return result + + +def get_blocked_slugs() -> frozenset[str]: + cached = getattr(frappe.local, "_jey_blocked_slugs", None) + if cached is not None: + return cached + slugs = {_slugify(dt) for dt in get_blocked_doctypes()} + for w in get_blocked_workspace_names(): + slugs.add(_slugify(w)) + result = frozenset(slugs) + frappe.local._jey_blocked_slugs = result + return result + + +def is_blocked_doctype(doctype: str) -> bool: + return doctype in get_blocked_doctypes() + + +def _deny(): + frappe.throw(_("Not Permitted"), frappe.PermissionError) + + +def before_request(): + """HTTP-level block. Works even for Administrator (who bypasses has_permission). + Covers /api/resource/, /app/, and any whitelisted method that + passes `doctype` in form_dict.""" + req = getattr(frappe.local, "request", None) + if req is None: + return + + path = getattr(req, "path", "") or "" + + if path.startswith("/api/resource/"): + remainder = path[len("/api/resource/"):] + slug = remainder.split("/", 1)[0] + doctype = unquote(slug) + if is_blocked_doctype(doctype): + _deny() + + if path.startswith("/app/"): + remainder = path[len("/app/"):] + first_segment = remainder.split("/", 1)[0].split("?", 1)[0].lower() + if first_segment and first_segment in get_blocked_slugs(): + _deny() + + form_dict = getattr(frappe.local, "form_dict", None) + if form_dict: + for key in ("doctype", "dt", "parent_doctype"): + value = form_dict.get(key) + if isinstance(value, str) and is_blocked_doctype(value): + _deny() + + +def has_permission(doc=None, ptype=None, user=None, debug=False): + """Per-doc hook. Applies to non-Administrator users that might otherwise have role access.""" + doctype = None + if doc is not None: + doctype = getattr(doc, "doctype", None) + if doctype is None and isinstance(doc, str): + doctype = doc + if not doctype: + return None + if is_blocked_doctype(doctype): + return False + if doctype == "Workspace": + module = getattr(doc, "module", None) + if module and module in BLOCKED_MODULES: + return False + return None + + +def _filter_list(container, key, predicate): + if isinstance(container, dict): + value = container.get(key) + else: + value = getattr(container, key, None) + if not isinstance(value, list): + return + filtered = [item for item in value if predicate(item)] + if isinstance(container, dict): + container[key] = filtered + else: + setattr(container, key, filtered) + + +def _module_of(item): + if isinstance(item, dict): + return item.get("module") or item.get("module_name") + return getattr(item, "module", None) or getattr(item, "module_name", None) + + +def extend_bootinfo(bootinfo): + """Strip blocked modules/doctypes from bootinfo so client router + never creates routes for them and the sidebar/switcher hide them.""" + blocked_doctypes = get_blocked_doctypes() + blocked_modules = BLOCKED_MODULES + + allowed_modules = getattr(bootinfo, "allowed_modules", None) + if isinstance(allowed_modules, list): + bootinfo.allowed_modules = [ + m for m in allowed_modules if _module_of(m) not in blocked_modules + ] + + modules = getattr(bootinfo, "modules", None) + if isinstance(modules, dict): + for mod_name in list(modules.keys()): + if mod_name in blocked_modules: + del modules[mod_name] + + user = getattr(bootinfo, "user", None) + if user is not None: + not_blocked = lambda dt: dt not in blocked_doctypes + for key in ( + "can_read", "can_write", "can_create", "can_cancel", + "can_delete", "can_get_report", "can_search", "can_export", + "can_print", "can_email", "all_read", "all_reports", + "can_select", "can_submit", + ): + _filter_list(user, key, not_blocked) + _filter_list(user, "allow_modules", lambda m: m not in blocked_modules) + + # bootinfo.workspaces = {"pages": [...], "blocks": [...], ...} + workspaces = getattr(bootinfo, "workspaces", None) + if isinstance(workspaces, dict): + for key in ("pages", "private_pages"): + pages = workspaces.get(key) + if isinstance(pages, list): + workspaces[key] = [p for p in pages if _module_of(p) not in blocked_modules] + + # Sidebar items keyed by section title + sidebar_items = getattr(bootinfo, "workspace_sidebar_item", None) + if isinstance(sidebar_items, dict): + for section_key in list(sidebar_items.keys()): + section = sidebar_items[section_key] + if isinstance(section, dict) and isinstance(section.get("items"), list): + section["items"] = [ + it for it in section["items"] if _module_of(it) not in blocked_modules + ] + + # Module-keyed workspace dict + mww = getattr(bootinfo, "module_wise_workspaces", None) + if isinstance(mww, dict): + for mod_name in list(mww.keys()): + if mod_name in blocked_modules: + del mww[mod_name] + + # Per-app data (workspaces & modules lists) + app_data = getattr(bootinfo, "app_data", None) + if isinstance(app_data, list): + for app in app_data: + if not isinstance(app, dict): + continue + ws = app.get("workspaces") + if isinstance(ws, list): + # Remove workspace names that belong to blocked modules. + # We compare by name since app_data workspaces are just names. + app["workspaces"] = [w for w in ws if _slugify(w) not in get_blocked_slugs()] + mods = app.get("modules") + if isinstance(mods, list): + app["modules"] = [m for m in mods if m not in blocked_modules] + + for key in ("allowed_pages", "allowed_workspaces", "sidebar_pages"): + items = getattr(bootinfo, key, None) + if isinstance(items, list): + setattr(bootinfo, key, [ + it for it in items if _module_of(it) not in blocked_modules + ]) + + # Desktop Icon records (used by legacy /desk page). Filter by label/link_to/name. + icons = getattr(bootinfo, "desktop_icons", None) + if isinstance(icons, list): + blocked_ws = get_blocked_workspace_names() + def _icon_blocked(i): + if _module_of(i) in blocked_modules: + return True + for key in ("label", "link_to", "name", "module_name"): + val = i.get(key) if isinstance(i, dict) else getattr(i, key, None) + if val and val in blocked_ws: + return True + return False + bootinfo.desktop_icons = [i for i in icons if not _icon_blocked(i)] diff --git a/jey_theme/hooks.py b/jey_theme/hooks.py index ee29aea..c364057 100644 --- a/jey_theme/hooks.py +++ b/jey_theme/hooks.py @@ -18,6 +18,16 @@ app_include_css = [ app_include_js = "/assets/jey_theme/js/jey_theme.js" +# Access control: скрытие модулей Quality Management / Subcontracting. +# Список заблокированных модулей — в jey_theme/access_control.py (BLOCKED_MODULES). +extend_bootinfo = "jey_theme.access_control.extend_bootinfo" + +before_request = ["jey_theme.access_control.before_request"] + +has_permission = { + "*": "jey_theme.access_control.has_permission", +} + # override_doctype_class = { # "Report": "jey_theme.custom_report.CustomReport" # } diff --git a/jey_theme/public/css/shared.css b/jey_theme/public/css/shared.css index accab87..c33c331 100644 --- a/jey_theme/public/css/shared.css +++ b/jey_theme/public/css/shared.css @@ -2,6 +2,19 @@ Jey Theme — Shared Styles (theme-independent) ========================================================================== */ +/* Hide Organization desktop icon by default */ +.desktop-icon[data-id="Organization"] { + display: none !important; +} + +/* Fallback hide for blocked modules (main filtering happens in access_control.py). + Desktop Icon records use label "Quality" and "Subcontracting" — match those. */ +.desktop-icon[data-id="Quality"], +.desktop-icon[data-id="Quality Management"], +.desktop-icon[data-id="Subcontracting"] { + display: none !important; +} + /* ========================================================================== Toggle Switch Checkboxes ========================================================================== */