From 01f4ad99712c8c980c58f93933135fcc6622a56e Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Wed, 29 Oct 2025 13:50:40 +0700 Subject: [PATCH] first commit --- README.md | 32 + __init__.py | 2 + __manifest__.py | 34 + __pycache__/__init__.cpython-312.pyc | Bin 0 -> 202 bytes security/ir.model.access.csv | 2 + views/user_access_rights_wizard_views.xml | 46 ++ wizard/__init__.py | 2 + wizard/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 228 bytes .../user_access_rights_wizard.cpython-312.pyc | Bin 0 -> 26113 bytes wizard/user_access_rights_wizard.py | 583 ++++++++++++++++++ 10 files changed, 701 insertions(+) create mode 100644 README.md create mode 100644 __init__.py create mode 100644 __manifest__.py create mode 100644 __pycache__/__init__.cpython-312.pyc create mode 100644 security/ir.model.access.csv create mode 100644 views/user_access_rights_wizard_views.xml create mode 100644 wizard/__init__.py create mode 100644 wizard/__pycache__/__init__.cpython-312.pyc create mode 100644 wizard/__pycache__/user_access_rights_wizard.cpython-312.pyc create mode 100644 wizard/user_access_rights_wizard.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..8e0ac48 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# User Access Rights Export + +Generate an Excel workbook that consolidates the access rights of every user in your Odoo 17 instance. + +## Features + +* One-click wizard accessible to system administrators. +* Summary worksheet listing all users with counts of granted ACLs and record rules. +* Summary worksheet listing each security group with user counts, ACL totals, and record rule totals. +* Dedicated worksheet per user including: + * Model access control list (CRUD) permissions derived from `ir.model.access`. + * Record rule visibility and domain definitions from `ir.rule`. +* Dedicated worksheet per security group including: + * Group-specific ACL permissions. + * Record rules that apply to the group. +* Workbook generated entirely in-memory using `xlsxwriter`. + +## Installation + +1. Copy the `user_access_rights_export` directory into your Odoo addons path. +2. Update your addons list and install the module via Apps. +3. Requires the Python package `xlsxwriter` (bundled with standard Odoo installations). + +## Usage + +1. Navigate to **Settings → Technical → User Access Rights Export**. +2. Click **Generate**. The module will produce and download an `.xlsx` file. +3. Open the Excel file to inspect summary metrics and per-user details. + +## Security + +Only members of the **Settings / Technical (System Administrator)** group can execute the export wizard. \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..83e278c --- /dev/null +++ b/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import wizard \ No newline at end of file diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..d4d885f --- /dev/null +++ b/__manifest__.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +{ + "name": "User Access Rights Export", + "version": "17.0.1.0.0", + "category": "Settings/Technical", + "summary": "Export detailed user access rights (model ACLs and record rules) to Excel", + "description": """ +User Access Rights Export +========================= + +Generate an Excel workbook detailing the access rights of all internal users. + +Features +-------- +* Summary worksheet with key metrics per user. +* Dedicated worksheet per user including: + - Model access rights (CRUD permissions). + - Record rules with domains and permissions. +* XLSX output generated in-memory via xlsxwriter. +""", + "author": "Suherdy Yacob", + "website": "https://www.example.com", + "license": "LGPL-3", + "depends": [ + "base", + ], + "data": [ + "security/ir.model.access.csv", + "views/user_access_rights_wizard_views.xml", + ], + "installable": True, + "application": False, + "auto_install": False, +} \ No newline at end of file diff --git a/__pycache__/__init__.cpython-312.pyc b/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c11139e80012cc43884704bff3e1685ed88774c5 GIT binary patch literal 202 zcmX@j%ge<81Z9&LGj)OVV-N=hn4pZ$0zk%eh7^Vr#vF!R#wbQchDs()=9i2>VNJ$c zY~`6%iA5=XnoPGCikN|7D;Yk6bpCQrv5G0EEXl~vOU_S8jmb~R&o?xWNiHoe$ + + + user.access.rights.wizard.form + user.access.rights.wizard + +
+ + + +
+

Generate a consolidated Excel workbook of user access rights.

+
    +
  • Includes model ACL permissions (Read, Write, Create, Delete) per user.
  • +
  • Captures applicable record rules and their domains.
  • +
  • Download starts automatically once the report is ready.
  • +
+
+
+ + + + +
+
+
+
+
+
+ + + Export User Access Rights + user.access.rights.wizard + form + new + + + +
\ No newline at end of file diff --git a/wizard/__init__.py b/wizard/__init__.py new file mode 100644 index 0000000..e10cd50 --- /dev/null +++ b/wizard/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import user_access_rights_wizard \ No newline at end of file diff --git a/wizard/__pycache__/__init__.cpython-312.pyc b/wizard/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..16922d984c9d2dbbb31928fc42c3900b96d89422 GIT binary patch literal 228 zcmX@j%ge<81a*@bGaZ2RV-N=hn4pZ$0zk%eh7^Vr#vF!R#wbQchDs()=9i2>VNJ$c zlBLC|Me&Kr$*IM~@kN>G870N><(XB9MJaxoOt%<{n1RYxGJFOZ_RBBDDyE>aBqKjB zIX@*eCO;)V-_SfJxwN<>KQ}QYB|on?28(H_6$SZ4B{2{~W8&j8^D;}~4ilhz-aA0JFS5&Hw-a literal 0 HcmV?d00001 diff --git a/wizard/__pycache__/user_access_rights_wizard.cpython-312.pyc b/wizard/__pycache__/user_access_rights_wizard.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..aabf96a00ae6bdce9e037120c6ea109052c72eed GIT binary patch literal 26113 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 UX&pbAty;xat@;-Vf00l6f0+o%g8%>k literal 0 HcmV?d00001 diff --git a/wizard/user_access_rights_wizard.py b/wizard/user_access_rights_wizard.py new file mode 100644 index 0000000..8e770a5 --- /dev/null +++ b/wizard/user_access_rights_wizard.py @@ -0,0 +1,583 @@ +# -*- coding: utf-8 -*- +import base64 +import io +import re +from datetime import datetime + +from odoo import _, api, fields, models +from odoo.exceptions import UserError +from odoo.tools.misc import xlsxwriter + + +class UserAccessRightsWizard(models.TransientModel): + _name = "user.access.rights.wizard" + _description = "User Access Rights Export Wizard" + + excel_file = fields.Binary(string="Excel File", readonly=True) + filename = fields.Char(string="File Name", readonly=True) + + def action_generate_report(self): + self.ensure_one() + if not xlsxwriter: + raise UserError(_("The python library xlsxwriter is required to export Excel files.")) + + users = self._get_users() + user_data = [] + user_summary_rows = [] + + for user in users: + group_names = ", ".join(user.groups_id.mapped("display_name")) or _("No Groups") + model_access = self._collect_model_access(user) + record_rules = self._collect_record_rules(user) + + user_summary_rows.append({ + "name": user.display_name, + "login": user.login or "", + "groups": group_names, + "active": self._bool_to_str(user.active), + "model_count": len(model_access), + "rule_count": len(record_rules), + }) + + user_data.append({ + "user": user, + "groups": group_names, + "model_access": model_access, + "record_rules": record_rules, + }) + + groups = self._get_groups() + group_data = [] + group_summary_rows = [] + + for group in groups: + model_access = self._collect_group_model_access(group) + record_rules = self._collect_group_record_rules(group) + users_in_group = group.users + + group_summary_rows.append({ + "name": group.display_name, + "technical_name": group.full_name, + "category": group.category_id.display_name or _("Uncategorized"), + "user_count": len(users_in_group), + "model_count": len(model_access), + "rule_count": len(record_rules), + }) + + group_data.append({ + "group": group, + "users": users_in_group, + "model_access": model_access, + "record_rules": record_rules, + }) + + output = io.BytesIO() + workbook = xlsxwriter.Workbook(output, {"in_memory": True}) + formats = self._build_formats(workbook) + used_sheet_names = set() + + try: + user_overview_sheet = self._make_unique_sheet_name(_("Users Overview"), used_sheet_names) + used_sheet_names.add(user_overview_sheet) + self._write_user_summary_sheet(workbook, formats, user_overview_sheet, user_summary_rows) + + if group_summary_rows: + group_overview_sheet = self._make_unique_sheet_name(_("Groups Overview"), used_sheet_names) + used_sheet_names.add(group_overview_sheet) + self._write_group_summary_sheet(workbook, formats, group_overview_sheet, group_summary_rows) + + for data in user_data: + sheet_name = self._make_unique_sheet_name( + data["user"].display_name or _("User"), + used_sheet_names, + ) + used_sheet_names.add(sheet_name) + self._write_user_sheet(workbook, formats, sheet_name, data) + + for data in group_data: + sheet_name = self._make_unique_sheet_name( + data["group"].display_name or _("Group"), + used_sheet_names, + ) + used_sheet_names.add(sheet_name) + self._write_group_sheet(workbook, formats, sheet_name, data) + finally: + workbook.close() + + file_content = output.getvalue() + filename = "user_access_rights_%s.xlsx" % datetime.now().strftime("%Y%m%d_%H%M%S") + + self.write({ + "excel_file": base64.b64encode(file_content), + "filename": filename, + }) + + return { + "type": "ir.actions.act_url", + "url": "/web/content/?model=%s&id=%s&field=excel_file&filename_field=filename&download=true" + % (self._name, self.id), + "target": "self", + } + + def _get_users(self): + return self.env["res.users"].sudo().with_context(active_test=False).search([], order="name") + + def _get_groups(self): + return self.env["res.groups"].sudo().with_context(active_test=False).search([], order="category_id, name") + + @api.model + def _collect_model_access(self, user): + user_groups = user.groups_id + acl_model = self.env["ir.model.access"].sudo().with_context(active_test=False) + acl_records = acl_model.search([], order="model_id, id") + result = [] + + for acl in acl_records: + applies = not acl.group_id or acl.group_id in user_groups + if not applies: + continue + + result.append({ + "model": acl.model_id.model, + "model_name": acl.model_id.name, + "group": acl.group_id.display_name if acl.group_id else _("All Users"), + "perm_read": self._bool_to_str(acl.perm_read), + "perm_write": self._bool_to_str(acl.perm_write), + "perm_create": self._bool_to_str(acl.perm_create), + "perm_unlink": self._bool_to_str(acl.perm_unlink), + }) + + return result + + @api.model + def _collect_group_model_access(self, group): + acl_model = self.env["ir.model.access"].sudo().with_context(active_test=False) + acl_records = acl_model.search([("group_id", "=", group.id)], order="model_id, id") + result = [] + for acl in acl_records: + result.append({ + "model": acl.model_id.model, + "model_name": acl.model_id.name, + "perm_read": self._bool_to_str(acl.perm_read), + "perm_write": self._bool_to_str(acl.perm_write), + "perm_create": self._bool_to_str(acl.perm_create), + "perm_unlink": self._bool_to_str(acl.perm_unlink), + }) + return result + + @api.model + def _collect_record_rules(self, user): + user_groups = user.groups_id + rule_model = self.env["ir.rule"].sudo().with_context(active_test=False) + rules = rule_model.search([], order="model_id, id") + result = [] + + for rule in rules: + is_global = bool(getattr(rule, "global", False)) + applies = is_global or (rule.groups and any(g in user_groups for g in rule.groups)) + if not applies: + continue + + group_names = ", ".join(rule.groups.mapped("display_name")) if rule.groups else _("All Users") + domain = rule.domain_force or "[]" + domain = re.sub(r"\s+", " ", domain).strip() + + result.append({ + "name": rule.name or _("Unnamed Rule"), + "model": rule.model_id.model, + "model_name": rule.model_id.name, + "domain": domain, + "group": group_names, + "global": self._bool_to_str(is_global), + "perm_read": self._bool_to_str(rule.perm_read), + "perm_write": self._bool_to_str(rule.perm_write), + "perm_create": self._bool_to_str(rule.perm_create), + "perm_unlink": self._bool_to_str(rule.perm_unlink), + }) + + return result + + @api.model + def _collect_group_record_rules(self, group): + rule_model = self.env["ir.rule"].sudo().with_context(active_test=False) + rules = rule_model.search([], order="model_id, id") + result = [] + + for rule in rules: + if group not in rule.groups: + continue + + domain = rule.domain_force or "[]" + domain = re.sub(r"\s+", " ", domain).strip() + + result.append({ + "name": rule.name or _("Unnamed Rule"), + "model": rule.model_id.model, + "model_name": rule.model_id.name, + "domain": domain, + "perm_read": self._bool_to_str(rule.perm_read), + "perm_write": self._bool_to_str(rule.perm_write), + "perm_create": self._bool_to_str(rule.perm_create), + "perm_unlink": self._bool_to_str(rule.perm_unlink), + }) + + return result + + @api.model + def _build_formats(self, workbook): + return { + "header": workbook.add_format({ + "bold": True, + "bg_color": "#F2F2F2", + "border": 1, + "text_wrap": True, + }), + "section": workbook.add_format({ + "bold": True, + "font_size": 12, + "bottom": 1, + }), + "text": workbook.add_format({ + "border": 1, + }), + "wrap": workbook.add_format({ + "border": 1, + "text_wrap": True, + }), + "center": workbook.add_format({ + "border": 1, + "align": "center", + }), + "title": workbook.add_format({ + "bold": True, + "font_size": 14, + }), + } + + def _write_user_summary_sheet(self, workbook, formats, sheet_name, rows): + worksheet = workbook.add_worksheet(sheet_name) + headers = [ + _("User"), + _("Login"), + _("Groups"), + _("Active"), + _("Model ACLs"), + _("Record Rules"), + ] + column_widths = [len(header) + 2 for header in headers] + + worksheet.freeze_panes(1, 0) + + for col, header in enumerate(headers): + worksheet.write(0, col, header, formats["header"]) + + for row_idx, data in enumerate(rows, start=1): + values = [ + data["name"], + data["login"], + data["groups"], + data["active"], + data["model_count"], + data["rule_count"], + ] + for col_idx, value in enumerate(values): + fmt = formats["wrap"] if col_idx == 2 else formats["text"] + worksheet.write(row_idx, col_idx, value, fmt) + column_widths[col_idx] = min( + max(column_widths[col_idx], len(str(value)) + 2), + 80, + ) + + for col_idx, width in enumerate(column_widths): + worksheet.set_column(col_idx, col_idx, width) + + def _write_group_summary_sheet(self, workbook, formats, sheet_name, rows): + worksheet = workbook.add_worksheet(sheet_name) + headers = [ + _("Group"), + _("Technical Name"), + _("Category"), + _("Users"), + _("Model ACLs"), + _("Record Rules"), + ] + column_widths = [len(header) + 2 for header in headers] + + worksheet.freeze_panes(1, 0) + + for col, header in enumerate(headers): + worksheet.write(0, col, header, formats["header"]) + + for row_idx, data in enumerate(rows, start=1): + values = [ + data["name"], + data["technical_name"], + data["category"], + data["user_count"], + data["model_count"], + data["rule_count"], + ] + for col_idx, value in enumerate(values): + fmt = formats["text"] + worksheet.write(row_idx, col_idx, value, fmt) + column_widths[col_idx] = min( + max(column_widths[col_idx], len(str(value)) + 2), + 80, + ) + + for col_idx, width in enumerate(column_widths): + worksheet.set_column(col_idx, col_idx, width) + + def _write_user_sheet(self, workbook, formats, sheet_name, data): + worksheet = workbook.add_worksheet(sheet_name) + row = 0 + + user = data["user"] + worksheet.write(row, 0, _("User Access Rights: %s") % (user.display_name,), formats["title"]) + row += 2 + + info_pairs = [ + (_("Name"), user.display_name or ""), + (_("Login"), user.login or ""), + (_("Email"), user.email or ""), + (_("Active"), self._bool_to_str(user.active)), + (_("Groups"), data["groups"]), + ] + + column_widths = [0, 0] + for label, value in info_pairs: + worksheet.write(row, 0, label, formats["header"]) + worksheet.write(row, 1, value, formats["wrap"]) + column_widths[0] = min(max(column_widths[0], len(label) + 2), 40) + column_widths[1] = min(max(column_widths[1], len(value) + 2), 80) + row += 1 + + worksheet.set_column(0, 0, column_widths[0] or 18) + worksheet.set_column(1, 1, column_widths[1] or 50) + + row += 1 + worksheet.write(row, 0, _("Model Access Rights"), formats["section"]) + row += 1 + + headers = [ + _("Model Technical Name"), + _("Model"), + _("Applies To Group"), + _("Read"), + _("Write"), + _("Create"), + _("Delete"), + ] + for col, header in enumerate(headers): + worksheet.write(row, col, header, formats["header"]) + row += 1 + + model_column_widths = [len(header) + 2 for header in headers] + for record in data["model_access"]: + values = [ + record["model"], + record["model_name"], + record["group"], + record["perm_read"], + record["perm_write"], + record["perm_create"], + record["perm_unlink"], + ] + for col_idx, value in enumerate(values): + fmt = formats["wrap"] if col_idx in (1, 2) else formats["center"] if col_idx >= 3 else formats["text"] + worksheet.write(row, col_idx, value, fmt) + model_column_widths[col_idx] = min( + max(model_column_widths[col_idx], len(str(value)) + 2), + 70, + ) + row += 1 + + for col_idx, width in enumerate(model_column_widths): + worksheet.set_column(col_idx, col_idx, width) + + row += 1 + worksheet.write(row, 0, _("Record Rules"), formats["section"]) + row += 1 + + rule_headers = [ + _("Rule Name"), + _("Model Technical Name"), + _("Model"), + _("Domain"), + _("Applies To Group"), + _("Global"), + _("Read"), + _("Write"), + _("Create"), + _("Delete"), + ] + for col, header in enumerate(rule_headers): + worksheet.write(row, col, header, formats["header"]) + row += 1 + + rule_column_widths = [len(header) + 2 for header in rule_headers] + for record in data["record_rules"]: + values = [ + record["name"], + record["model"], + record["model_name"], + record["domain"], + record["group"], + record["global"], + record["perm_read"], + record["perm_write"], + record["perm_create"], + record["perm_unlink"], + ] + for col_idx, value in enumerate(values): + if col_idx in (3, 4): + fmt = formats["wrap"] + elif col_idx >= 5: + fmt = formats["center"] + else: + fmt = formats["text"] + worksheet.write(row, col_idx, value, fmt) + rule_column_widths[col_idx] = min( + max(rule_column_widths[col_idx], len(str(value)) + 2), + 90 if col_idx == 3 else 70, + ) + row += 1 + + for col_idx, width in enumerate(rule_column_widths): + worksheet.set_column(col_idx, col_idx, width) + + worksheet.freeze_panes(4 + len(data["model_access"]), 0) + + def _write_group_sheet(self, workbook, formats, sheet_name, data): + worksheet = workbook.add_worksheet(sheet_name) + row = 0 + + group = data["group"] + worksheet.write(row, 0, _("Group Access Rights: %s") % (group.display_name,), formats["title"]) + row += 2 + + user_names = ", ".join(data["users"].mapped("display_name")) or _("No Users") + + info_pairs = [ + (_("Name"), group.display_name or ""), + (_("Technical Name"), group.full_name or ""), + (_("Category"), group.category_id.display_name or _("Uncategorized")), + (_("Users"), user_names), + ] + + column_widths = [0, 0] + for label, value in info_pairs: + worksheet.write(row, 0, label, formats["header"]) + worksheet.write(row, 1, value, formats["wrap"]) + column_widths[0] = min(max(column_widths[0], len(label) + 2), 40) + column_widths[1] = min(max(column_widths[1], len(value) + 2), 80) + row += 1 + + worksheet.set_column(0, 0, column_widths[0] or 18) + worksheet.set_column(1, 1, column_widths[1] or 50) + + row += 1 + worksheet.write(row, 0, _("Model Access Rights"), formats["section"]) + row += 1 + + headers = [ + _("Model Technical Name"), + _("Model"), + _("Read"), + _("Write"), + _("Create"), + _("Delete"), + ] + for col, header in enumerate(headers): + worksheet.write(row, col, header, formats["header"]) + row += 1 + + model_column_widths = [len(header) + 2 for header in headers] + for record in data["model_access"]: + values = [ + record["model"], + record["model_name"], + record["perm_read"], + record["perm_write"], + record["perm_create"], + record["perm_unlink"], + ] + for col_idx, value in enumerate(values): + fmt = formats["wrap"] if col_idx == 1 else formats["center"] if col_idx >= 2 else formats["text"] + worksheet.write(row, col_idx, value, fmt) + model_column_widths[col_idx] = min( + max(model_column_widths[col_idx], len(str(value)) + 2), + 70, + ) + row += 1 + + for col_idx, width in enumerate(model_column_widths): + worksheet.set_column(col_idx, col_idx, width) + + row += 1 + worksheet.write(row, 0, _("Record Rules"), formats["section"]) + row += 1 + + rule_headers = [ + _("Rule Name"), + _("Model Technical Name"), + _("Model"), + _("Domain"), + _("Read"), + _("Write"), + _("Create"), + _("Delete"), + ] + for col, header in enumerate(rule_headers): + worksheet.write(row, col, header, formats["header"]) + row += 1 + + rule_column_widths = [len(header) + 2 for header in rule_headers] + for record in data["record_rules"]: + values = [ + record["name"], + record["model"], + record["model_name"], + record["domain"], + record["perm_read"], + record["perm_write"], + record["perm_create"], + record["perm_unlink"], + ] + for col_idx, value in enumerate(values): + if col_idx == 3: + fmt = formats["wrap"] + elif col_idx >= 4: + fmt = formats["center"] + else: + fmt = formats["text"] + worksheet.write(row, col_idx, value, fmt) + rule_column_widths[col_idx] = min( + max(rule_column_widths[col_idx], len(str(value)) + 2), + 90 if col_idx == 3 else 70, + ) + row += 1 + + for col_idx, width in enumerate(rule_column_widths): + worksheet.set_column(col_idx, col_idx, width) + + worksheet.freeze_panes(4 + len(data["model_access"]), 0) + + @api.model + def _make_unique_sheet_name(self, base_name, used_names): + sanitized = re.sub(r"[\[\]\*\?:\\/]", "", base_name or _("Sheet")) + sanitized = sanitized.strip() or _("Sheet") + sanitized = sanitized[:31] + candidate = sanitized + index = 2 + + while candidate in used_names: + suffix = f" ({index})" + candidate = (sanitized[:31 - len(suffix)] + suffix) if len(sanitized) + len(suffix) > 31 else sanitized + suffix + index += 1 + + return candidate + + @api.model + def _bool_to_str(self, value): + return _("Yes") if value else _("No") \ No newline at end of file