commit 01f4ad99712c8c980c58f93933135fcc6622a56e Author: Suherdy Yacob Date: Wed Oct 29 13:50:40 2025 +0700 first commit 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 0000000..c11139e Binary files /dev/null and b/__pycache__/__init__.cpython-312.pyc differ diff --git a/security/ir.model.access.csv b/security/ir.model.access.csv new file mode 100644 index 0000000..fcf214f --- /dev/null +++ b/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_user_access_rights_wizard,user.access.rights.wizard,model_user_access_rights_wizard,base.group_system,1,1,1,1 \ No newline at end of file diff --git a/views/user_access_rights_wizard_views.xml b/views/user_access_rights_wizard_views.xml new file mode 100644 index 0000000..44360cd --- /dev/null +++ b/views/user_access_rights_wizard_views.xml @@ -0,0 +1,46 @@ + + + + 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 0000000..16922d9 Binary files /dev/null and b/wizard/__pycache__/__init__.cpython-312.pyc differ 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 0000000..aabf96a Binary files /dev/null and b/wizard/__pycache__/user_access_rights_wizard.cpython-312.pyc differ 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