diff --git a/README.md b/README.md index 8e0ac48..b1fa7b5 100644 --- a/README.md +++ b/README.md @@ -1,32 +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 - +# 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 index 83e278c..e50c250 100644 --- a/__init__.py +++ b/__init__.py @@ -1,2 +1,2 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8 -*- from . import wizard \ No newline at end of file diff --git a/__manifest__.py b/__manifest__.py index d4d885f..71bb0d4 100644 --- a/__manifest__.py +++ b/__manifest__.py @@ -1,34 +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, +# -*- 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-310.pyc b/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..fc847f1 Binary files /dev/null and b/__pycache__/__init__.cpython-310.pyc differ diff --git a/security/ir.model.access.csv b/security/ir.model.access.csv index fcf214f..a9584fb 100644 --- a/security/ir.model.access.csv +++ b/security/ir.model.access.csv @@ -1,2 +1,2 @@ -id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +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 index 44360cd..ff92a24 100644 --- a/views/user_access_rights_wizard_views.xml +++ b/views/user_access_rights_wizard_views.xml @@ -1,46 +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 - - - + + + + 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 index e10cd50..6b744f2 100644 --- a/wizard/__init__.py +++ b/wizard/__init__.py @@ -1,2 +1,2 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8 -*- from . import user_access_rights_wizard \ No newline at end of file diff --git a/wizard/__pycache__/__init__.cpython-310.pyc b/wizard/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..66c332d Binary files /dev/null and b/wizard/__pycache__/__init__.cpython-310.pyc differ diff --git a/wizard/__pycache__/user_access_rights_wizard.cpython-310.pyc b/wizard/__pycache__/user_access_rights_wizard.cpython-310.pyc new file mode 100644 index 0000000..6db837b Binary files /dev/null and b/wizard/__pycache__/user_access_rights_wizard.cpython-310.pyc differ diff --git a/wizard/user_access_rights_wizard.py b/wizard/user_access_rights_wizard.py index 8e770a5..4ffabdf 100644 --- a/wizard/user_access_rights_wizard.py +++ b/wizard/user_access_rights_wizard.py @@ -1,583 +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): +# -*- 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