diff --git a/addons_extensions/menu_control_center/__init__.py b/addons_extensions/menu_control_center/__init__.py index 9a7e03ede..a66486a18 100644 --- a/addons_extensions/menu_control_center/__init__.py +++ b/addons_extensions/menu_control_center/__init__.py @@ -1 +1 @@ -from . import models \ No newline at end of file +from . import controllers, models \ No newline at end of file diff --git a/addons_extensions/menu_control_center/__manifest__.py b/addons_extensions/menu_control_center/__manifest__.py index 385729848..48e8d8b62 100644 --- a/addons_extensions/menu_control_center/__manifest__.py +++ b/addons_extensions/menu_control_center/__manifest__.py @@ -10,10 +10,13 @@ 'category': 'Tools', 'author': 'PRANAY', 'website': 'https://ftprotech.in', - 'depends': ['base','hr'], + 'depends': ['base','hr','web','one2many_search_widget'], 'data': [ 'security/ir.model.access.csv', 'data/data.xml', + 'views/masters.xml', + 'views/groups.xml', + 'views/login.xml', 'views/menu_access_control_views.xml', ], # 'assets': { diff --git a/addons_extensions/menu_control_center/controllers/__init__.py b/addons_extensions/menu_control_center/controllers/__init__.py new file mode 100644 index 000000000..deec4a8b8 --- /dev/null +++ b/addons_extensions/menu_control_center/controllers/__init__.py @@ -0,0 +1 @@ +from . import main \ No newline at end of file diff --git a/addons_extensions/menu_control_center/controllers/main.py b/addons_extensions/menu_control_center/controllers/main.py new file mode 100644 index 000000000..7e333b9bd --- /dev/null +++ b/addons_extensions/menu_control_center/controllers/main.py @@ -0,0 +1,28 @@ +from odoo import http, _ +from odoo.http import request +from odoo.addons.web.controllers.home import Home +import werkzeug + + +class CustomMasterLogin(Home): + + @http.route() + def web_login(self, *args, **kw): + # Call the original Odoo login + master_selected = kw.get('master_select') + + response = super(CustomMasterLogin, self).web_login(*args, **kw) + + # We only modify the QWeb response (GET request) + if response.is_qweb: + # load your masters + masters = request.env['master.control'].sudo().search([]) + response.qcontext['masters'] = masters + request.env['ir.ui.menu'].sudo().clear_caches() + request.env['ir.ui.menu'].sudo()._visible_menu_ids() + + # After successful login + if request.session.uid and master_selected: + request.session['active_master'] = master_selected + + return response diff --git a/addons_extensions/menu_control_center/models/__init__.py b/addons_extensions/menu_control_center/models/__init__.py index 52e35f001..ba2150479 100644 --- a/addons_extensions/menu_control_center/models/__init__.py +++ b/addons_extensions/menu_control_center/models/__init__.py @@ -1,2 +1,5 @@ +from . import masters +from . import ir_http +from . import groups from . import models from . import menu \ No newline at end of file diff --git a/addons_extensions/menu_control_center/models/groups.py b/addons_extensions/menu_control_center/models/groups.py new file mode 100644 index 000000000..fa05dbd20 --- /dev/null +++ b/addons_extensions/menu_control_center/models/groups.py @@ -0,0 +1,73 @@ +from odoo import api, fields, models +from odoo.http import request + + +class ResGroups(models.Model): + _inherit = 'res.groups' + + is_visible_for_master = fields.Boolean( + compute='_compute_is_visible_for_master', + search='_search_is_visible_for_master' + ) + + @api.depends_context('active_master') + def _compute_is_visible_for_master(self): + master_code = request.session.active_master + if not master_code: + self.update({'is_visible_for_master': True}) + return + + master_control = self.env['master.control'].sudo().search([ + ('code', '=', master_code) + ], limit=1) + + if not master_control: + self.update({'is_visible_for_master': True}) + return + + # If NO access_group_ids -> show ALL groups + if not master_control.access_group_ids: + self.update({'is_visible_for_master': True}) + return + + visible_group_ids = master_control.access_group_ids.filtered( + lambda line: line.show_group + ).mapped('group_id').ids + + # If there are no 'show_group = True' -> show ALL groups + if not visible_group_ids: + self.update({'is_visible_for_master': True}) + return + + for group in self: + group.is_visible_for_master = group.id in visible_group_ids + + + def _search_is_visible_for_master(self, operator, value): + if operator != '=' or not value: + return [] + + master_code = request.session.active_master + if not master_code: + return [] + + master_control = self.env['master.control'].sudo().search([ + ('code', '=', master_code) + ], limit=1) + + if not master_control: + return [] + + # If NO access_group_ids → show ALL groups → no domain filter + if not master_control.access_group_ids: + return [] + + visible_group_ids = master_control.access_group_ids.filtered( + lambda line: line.show_group + ).mapped('group_id').ids + + # If none of the lines have show_group=True → show ALL + if not visible_group_ids: + return [] + + return [('id', 'in', visible_group_ids)] diff --git a/addons_extensions/menu_control_center/models/ir_http.py b/addons_extensions/menu_control_center/models/ir_http.py new file mode 100644 index 000000000..4021b1d47 --- /dev/null +++ b/addons_extensions/menu_control_center/models/ir_http.py @@ -0,0 +1,16 @@ +import odoo +from odoo import api, models, fields +from odoo.http import request +from odoo import http + +class IrHttp(models.AbstractModel): + _inherit = 'ir.http' + + def session_info(self): + info = super().session_info() + active_master = http.request.session.get("active_master") + if active_master: + info['user_context']['active_master'] = active_master + else: + info['user_context']['active_master'] = '' + return info \ No newline at end of file diff --git a/addons_extensions/menu_control_center/models/masters.py b/addons_extensions/menu_control_center/models/masters.py new file mode 100644 index 000000000..42f39a43c --- /dev/null +++ b/addons_extensions/menu_control_center/models/masters.py @@ -0,0 +1,102 @@ +from odoo import models, fields, _, api + +class MasterControl(models.Model): + _name = 'master.control' + _description = 'Master Control' + + sequence = fields.Integer() + name = fields.Char(string='Master Name', required=True) + code = fields.Char(string='Code', required=True) + default_show = fields.Boolean(default=True) + access_group_ids = fields.One2many('group.access.line','master_control_id',string='Roles') + + @api.depends('name', 'code') + def _compute_display_name(self): + for record in self: + if record.name: + record.display_name = record.name + (f' ({record.code})' if record.code else '') + else: + record.display_name = False + + def action_generate_groups(self): + """Generate category → groups list""" + for rec in self: + + # Clear old groups + rec.access_group_ids.unlink() + + groups = self.env['res.groups'].sudo().search([],order='category_id') + show_group = True if rec.default_show else False + print(show_group) + for grp in groups: + self.env['group.access.line'].create({ + 'master_control_id': rec.id, + 'group_id': grp.id, + 'show_group': show_group, + }) + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Success'), + 'message': _('Groups generated successfully!'), + 'type': 'success', + } + } + + # ----------------------------------------- + # UPDATE GROUPS (Detect new groups) + # ----------------------------------------- + def action_update_groups(self): + for rec in self: + created_count = 0 + + existing_ids = set(rec.access_group_ids.mapped('group_id.id')) + + categories = self.env['ir.module.category'].search([]) + + for category in categories: + groups = self.env['res.groups'].search([ + ('category_id', '=', category.id) + ]) + + for grp in groups: + # create only missing group + if grp.id not in existing_ids: + rec.access_group_ids.create({ + 'master_control_id': rec.id, + 'category_id': category.id, + 'group_id': grp.id, + 'show_group': True if rec.default_show else False, + }) + created_count += 1 + existing_ids.add(grp.id) + + if created_count: + message = f"Added {created_count} new groups." + msg_type = "success" + else: + message = "No new groups found." + msg_type = "info" + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Group Update'), + 'message': _(message), + 'type': msg_type, + } + } + + +class GroupsAccessLine(models.Model): + _name = 'group.access.line' + _description = 'Group Access Line' + _rec_name = 'group_id' + + category_id = fields.Many2one('ir.module.category', related='group_id.category_id') + group_id = fields.Many2one('res.groups', string="Role") + show_group = fields.Boolean(string="Show", default=True) + master_control_id = fields.Many2one('master.control') \ No newline at end of file diff --git a/addons_extensions/menu_control_center/models/menu.py b/addons_extensions/menu_control_center/models/menu.py index c82f101c0..36fe416a0 100644 --- a/addons_extensions/menu_control_center/models/menu.py +++ b/addons_extensions/menu_control_center/models/menu.py @@ -1,6 +1,8 @@ +from passlib.apps import master_context + from odoo import models, fields, api, tools, _ from collections import defaultdict - +from odoo.http import request class IrUiMenu(models.Model): _inherit = 'ir.ui.menu' @@ -8,38 +10,100 @@ class IrUiMenu(models.Model): @api.model @tools.ormcache('frozenset(self.env.user.groups_id.ids)', 'debug') def _visible_menu_ids(self, debug=False): - """ Return the ids of the menu items visible to the user. """ - # retrieve all menus, and determine which ones are visible + """Return the IDs of menu items visible to the current user based on permissions and active master.""" + # Clear existing cache to ensure fresh menu visibility calculation + self.env['ir.ui.menu'].sudo().clear_caches() + + # Retrieve all menus with required fields context = {'ir.ui.menu.full_list': True} menus = self.with_context(context).search_fetch([], ['action', 'parent_id']).sudo() - # first discard all menus with groups the user does not have + # Get active master and control configuration + active_master = request.session.get('active_master') + master_control = False + control_unit = False + if active_master: + master_control = self.env['master.control'].sudo().search( + [('code', '=', active_master)], limit=1 + ) + if master_control: + control_unit = self.env['menu.access.control'].sudo().search([ + ('user_ids', 'ilike', self.env.user.id), + ('master_control', '=', master_control.id) + ]) + + # Get user groups and exclude technical group in non-debug mode group_ids = set(self.env.user._get_group_ids()) if not debug: - hide_menus_list = self.env['menu.access.control'].sudo().search([('user_ids','ilike',self.env.user.id)]).access_menu_line_ids.filtered(lambda menu: not(menu.is_main_menu)).menu_id.ids + group_ids -= { + self.env['ir.model.data']._xmlid_to_res_id( + 'base.group_no_one', raise_if_not_found=False + ) + } - menus = menus.filtered(lambda menu: (menu.id not in hide_menus_list)) - group_ids = group_ids - { - self.env['ir.model.data']._xmlid_to_res_id('base.group_no_one', raise_if_not_found=False)} + # Filter menus by group permissions menus = menus.filtered( - lambda menu: not (menu.groups_id and group_ids.isdisjoint(menu.groups_id._ids))) + lambda menu: not (menu.groups_id and group_ids.isdisjoint(menu.groups_id._ids)) + ) - # take apart menus that have an action + # Determine menus to hide based on access control + hide_menus_list = self._get_hidden_menu_ids(control_unit, master_control, debug) + menus = menus.filtered(lambda menu: menu.id not in hide_menus_list) + + # Process menus with actions + visible = self._process_action_menus(menus) + + return set(visible.ids) + + def _get_hidden_menu_ids(self, control_unit, master_control, debug): + """Helper method to determine menu IDs that should be hidden from the user.""" + if debug and control_unit: + # In debug mode with control unit, use its specific menu restrictions + parent_menus = control_unit.access_menu_line_ids.filtered( + lambda menu: not menu.is_main_menu + ).menu_id.ids + sub_menus = control_unit.access_sub_menu_line_ids.filtered( + lambda menu: not menu.is_main_menu + ).menu_id.ids + elif not debug: + # In non-debug mode, determine menus to hide based on control configuration + domain = [('user_ids', 'ilike', self.env.user.id)] + if master_control: + domain.append(('master_control', '=', master_control.id)) + + access_controls = self.env['menu.access.control'].sudo().search(domain) + parent_menus = access_controls.access_menu_line_ids.filtered( + lambda menu: not menu.is_main_menu + ).menu_id.ids + sub_menus = access_controls.access_sub_menu_line_ids.filtered( + lambda menu: not menu.is_main_menu + ).menu_id.ids + else: + # Default case: no menus to hide + return [] + + return list(set(parent_menus + sub_menus)) + + def _process_action_menus(self, menus): + """Process menus with actions and determine visibility based on model access.""" + # Separate menus with actions from folder menus actions_by_model = defaultdict(set) for action in menus.mapped('action'): if action: actions_by_model[action._name].add(action.id) + existing_actions = { action for model_name, action_ids in actions_by_model.items() for action in self.env[model_name].browse(action_ids).exists() } + action_menus = menus.filtered(lambda m: m.action and m.action in existing_actions) folder_menus = menus - action_menus visible = self.browse() - # process action menus, check whether their action is allowed + # Model access check configuration access = self.env['ir.model.access'] MODEL_BY_TYPE = { 'ir.actions.act_window': 'res_model', @@ -47,21 +111,22 @@ class IrUiMenu(models.Model): 'ir.actions.server': 'model_name', } - # performance trick: determine the ids to prefetch by type + # Prefetch action data for performance prefetch_ids = defaultdict(list) for action in action_menus.mapped('action'): prefetch_ids[action._name].append(action.id) + # Check access for each action menu for menu in action_menus: - action = menu.action - action = action.with_prefetch(prefetch_ids[action._name]) + action = menu.action.with_prefetch(prefetch_ids[action._name]) model_name = action._name in MODEL_BY_TYPE and action[MODEL_BY_TYPE[action._name]] - if not model_name or access.check(model_name, 'read', False): - # make menu visible, and its folder ancestors, too - visible += menu - menu = menu.parent_id - while menu and menu in folder_menus and menu not in visible: - visible += menu - menu = menu.parent_id - return set(visible.ids) \ No newline at end of file + if not model_name or access.check(model_name, 'read', False): + # Make menu visible and its folder ancestors + visible += menu + parent = menu.parent_id + while parent and parent in folder_menus and parent not in visible: + visible += parent + parent = parent.parent_id + + return visible \ No newline at end of file diff --git a/addons_extensions/menu_control_center/models/models.py b/addons_extensions/menu_control_center/models/models.py index a1f11067e..a33ec72a0 100644 --- a/addons_extensions/menu_control_center/models/models.py +++ b/addons_extensions/menu_control_center/models/models.py @@ -28,31 +28,144 @@ class MenuAccessControl(models.Model): _rec_name = 'control_unit' _sql_constraints = [ - ('unique_control_unit', 'UNIQUE(control_unit)', "Only one service can exist with a specific control_unit. Please don't confuse me 🤪.") + ('unique_control_unit', 'UNIQUE(control_unit, master_control)', "Only one service can exist with a specific control_unit & Master. Please don't confuse me 🤪.") ] control_unit = fields.Many2one('menu.control.units',required=True) user_ids = fields.Many2many('res.users', string="Users", related='control_unit.user_ids') + master_control = fields.Many2one('master.control') access_menu_line_ids = fields.One2many( 'menu.access.line', 'access_control_id', - string="Accessible Menus" + string="Accessible Menus", domain=[('menu_id.parent_id','=',False)] ) + access_sub_menu_line_ids = fields.One2many('menu.access.line', 'access_control_id', + string="Accessible Menus", domain=[('menu_id.parent_id','!=',False)] + ) + + def _get_all_submenus(self, menu): + """Returns all submenus recursively for a given menu.""" + submenus = self.env['ir.ui.menu'].search([('parent_id', '=', menu.id), ('active', '=', True)]) + all_subs = submenus + for sm in submenus: + all_subs |= self._get_all_submenus(sm) + return all_subs + def action_generate_menus(self): - """Button to fetch active top-level menus and populate access lines.""" - menu_lines = [] - active_menus = self.env['ir.ui.menu'].search([ - ('parent_id', '=', False), # top-level menus - ('active', '=', True) - ]) - for menu in active_menus: - menu_lines.append((0, 0, { - 'menu_id': menu.id, - 'is_main_menu': True - })) - self.access_menu_line_ids = menu_lines + """ + Generate main menus and all submenus (recursive), + and set access_line_id for every submenu. + """ + for rec in self: + + # clear old menus + rec.access_menu_line_ids.unlink() + rec.access_sub_menu_line_ids.unlink() + + active_menus = self.env['ir.ui.menu'].search([ + ('parent_id', '=', False), + ('active', '=', True) + ]) + + for menu in active_menus: + + # 1️⃣ Create main menu line + main_line = self.env['menu.access.line'].create({ + 'access_control_id': rec.id, + 'menu_id': menu.id, + 'is_main_menu': True, + }) + + # 2️⃣ Fetch all recursive submenus + submenus = self._get_all_submenus(menu) + + # 3️⃣ Create submenu lines with correct parent + for sm in submenus: + self.env['menu.access.line'].create({ + 'access_control_id': rec.id, + 'menu_id': sm.id, + 'is_main_menu': True, + 'access_line_id': main_line.id, # important + }) + + def action_update_menus(self): + line = self.env['menu.access.line'] + menu = self.env['ir.ui.menu'] + + for rec in self: + created_count = 0 + + # All existing menu IDs across BOTH One2manys + existing_menu_ids = set( + rec.access_menu_line_ids.mapped('menu_id.id') + + rec.access_sub_menu_line_ids.mapped('menu_id.id') + ) + + # ---------- Step 1: Ensure all top-level menus exist ---------- + top_menus = menu.search([ + ('parent_id', '=', False), + ('active', '=', True), + ]) + + for menu in top_menus: + + # Create missing MAIN MENU + main_line = line.search([ + ('access_control_id', '=', rec.id), + ('menu_id', '=', menu.id), + ('access_line_id', '=', False) + ], limit=1) + + if not main_line: + main_line = line.create({ + 'access_control_id': rec.id, + 'menu_id': menu.id, + 'is_main_menu': True, + }) + created_count += 1 + existing_menu_ids.add(menu.id) + + # ---------- Step 2: Ensure all SUBMENUS exist ---------- + submenus = rec._get_all_submenus(menu) + + for sm in submenus: + + # If submenu is missing → create it + if sm.id not in existing_menu_ids: + line.create({ + 'access_control_id': rec.id, + 'menu_id': sm.id, + 'is_main_menu': True, + 'access_line_id': main_line.id, + }) + created_count += 1 + existing_menu_ids.add(sm.id) + + # ---------- Notification ---------- + if created_count: + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Success'), + 'message': _('Added %s new menu(s) (including submenus)') % created_count, + 'type': 'success', + 'sticky': False, + } + } + else: + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Info'), + 'message': _('No new menus found to add.'), + 'type': 'info', + 'sticky': False, + } + } class MenuAccessLine(models.Model): @@ -63,3 +176,29 @@ class MenuAccessLine(models.Model): access_control_id = fields.Many2one('menu.access.control', ondelete='cascade') menu_id = fields.Many2one('ir.ui.menu', string="Menu") is_main_menu = fields.Boolean(string="Is Main Menu", default=True) + parent_menu = fields.Many2one('ir.ui.menu',related='menu_id.parent_id') + access_line_id = fields.Many2one('menu.access.line') + control_unit = fields.Many2one( + 'menu.control.units', + related='access_control_id.control_unit', + store=True + ) + + def open_submenus_popup_view(self): + self.ensure_one() + + return { + "name": _("Sub Menus"), + "type": "ir.actions.act_window", + "res_model": "menu.access.line", + "view_mode": "list,form", + "views": [ + (self.env.ref("menu_control_center.view_submenu_line_list").id, "list"), + ], + "target": "new", + "domain": [("access_line_id", "=", self.id)], + "context": {"default_access_line_id": self.id}, + } + + + diff --git a/addons_extensions/menu_control_center/security/ir.model.access.csv b/addons_extensions/menu_control_center/security/ir.model.access.csv index d7edab9d1..f6e0f1e02 100644 --- a/addons_extensions/menu_control_center/security/ir.model.access.csv +++ b/addons_extensions/menu_control_center/security/ir.model.access.csv @@ -1,4 +1,7 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_menu_access_control,access.menu.access.control,model_menu_access_control,hr.group_hr_manager,1,1,1,1 access_menu_access_line,access.menu.access.line,model_menu_access_line,hr.group_hr_manager,1,1,1,1 -access_menu_control_units,access.menu.control.units,model_menu_control_units,hr.group_hr_manager,1,1,1,1 \ No newline at end of file +access_menu_control_units,access.menu.control.units,model_menu_control_units,hr.group_hr_manager,1,1,1,1 +access_master_control_public,master.control.public,model_master_control,base.group_public,1,0,0,0 +access_master_control_hr,master.control.hr,model_master_control,hr.group_hr_manager,1,1,1,1 +group_access_line_access,group_access_line_access,model_group_access_line,,1,1,1,1 diff --git a/addons_extensions/menu_control_center/views/groups.xml b/addons_extensions/menu_control_center/views/groups.xml new file mode 100644 index 000000000..a6e2deabe --- /dev/null +++ b/addons_extensions/menu_control_center/views/groups.xml @@ -0,0 +1,19 @@ + + + + + Roles + res.groups + [('is_visible_for_master', '=', True)] + + + + + + + + + + + + \ No newline at end of file diff --git a/addons_extensions/menu_control_center/views/login.xml b/addons_extensions/menu_control_center/views/login.xml new file mode 100644 index 000000000..dc5e0d5a5 --- /dev/null +++ b/addons_extensions/menu_control_center/views/login.xml @@ -0,0 +1,17 @@ + + + + \ No newline at end of file diff --git a/addons_extensions/menu_control_center/views/masters.xml b/addons_extensions/menu_control_center/views/masters.xml new file mode 100644 index 000000000..b15db1e26 --- /dev/null +++ b/addons_extensions/menu_control_center/views/masters.xml @@ -0,0 +1,76 @@ + + + + + master.control.list + master.control + + + + + + + + + + + master.control.form + master.control + +
+
+
+ + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + Master Control + master.control + list,form + + + + + + + + +
\ No newline at end of file diff --git a/addons_extensions/menu_control_center/views/menu_access_control_views.xml b/addons_extensions/menu_control_center/views/menu_access_control_views.xml index 7dd71421d..3460bb2ac 100644 --- a/addons_extensions/menu_control_center/views/menu_access_control_views.xml +++ b/addons_extensions/menu_control_center/views/menu_access_control_views.xml @@ -1,5 +1,26 @@ + + menu.access.line.submenu.list + menu.access.line + + + + + + + + + + + + + Sub Menus + menu.access.line + list + new + + menu.control.units.list menu.control.units @@ -48,6 +69,7 @@ + @@ -59,15 +81,42 @@ + - + + + + diff --git a/addons_extensions/onlyoffice_odoo/static/src/css/form_gallery.scss b/addons_extensions/onlyoffice_odoo/static/src/css/form_gallery.scss new file mode 100644 index 000000000..67b5f5cd7 --- /dev/null +++ b/addons_extensions/onlyoffice_odoo/static/src/css/form_gallery.scss @@ -0,0 +1,57 @@ +.o_onlyoffice_kanban_renderer { + row-gap: 30px; + column-gap: 30px; + background: none; + + .o_onlyoffice_kanban_record { + width: 200px; + border: 1px solid; + border-color: var(--black-25); + + .o_onlyoffice_record_selector { + color: var(--black-25); + position: absolute; + top: 8px; + right: 8px; + font-size: 16px; + z-index: 9; + cursor: pointer; + + background: rgba(255, 255, 255, 0.66); + border-radius: 50%; + width: 13px; + height: 13px; + display: flex; + align-items: center; + justify-content: center; + } + + .o_onlyoffice_record_selected { + opacity: 1; + border: 1px solid var(--primary); + box-shadow: 0 0 0 1px var(--primary); + background-color: var(--body-bg); + + .o_onlyoffice_record_selector::before { + color: var(--primary); + content: "\f058"; + } + } + } + + .o_onlyoffice_kanban_previewer { + cursor: zoom-in; + + .o_onlyoffice_kanban_image { + width: 100%; + height: 100%; + } + } + + .o_onlyoffice_kanban_details_wrapper { + flex-direction: row; + min-height: auto; + column-gap: 10px; + align-items: baseline; + } +} diff --git a/addons_extensions/onlyoffice_odoo/static/src/css/onlyoffice_link_container.png b/addons_extensions/onlyoffice_odoo/static/src/css/onlyoffice_link_container.png new file mode 100644 index 000000000..372532c98 Binary files /dev/null and b/addons_extensions/onlyoffice_odoo/static/src/css/onlyoffice_link_container.png differ diff --git a/addons_extensions/onlyoffice_odoo/static/src/css/onlyoffice_link_container_mobile.png b/addons_extensions/onlyoffice_odoo/static/src/css/onlyoffice_link_container_mobile.png new file mode 100644 index 000000000..889098137 Binary files /dev/null and b/addons_extensions/onlyoffice_odoo/static/src/css/onlyoffice_link_container_mobile.png differ diff --git a/addons_extensions/onlyoffice_odoo/static/src/css/onlyoffice_preview.scss b/addons_extensions/onlyoffice_odoo/static/src/css/onlyoffice_preview.scss new file mode 100644 index 000000000..96824b90c --- /dev/null +++ b/addons_extensions/onlyoffice_odoo/static/src/css/onlyoffice_preview.scss @@ -0,0 +1,47 @@ +.o-overlay-item { + &:has(.o-onlyoffice-preview) { + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 9999; + } +} + +.o-onlyoffice-overlay-item { + top: 0; + width: 100%; + height: 100%; +} + +.o-onlyoffice-preview { + position: absolute; + z-index: 9998; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + background-color: rgba(0, 0, 0, 0.7); + + .o-onlyoffice-preview-header { + display: flex; + min-height: 40px; + align-items: center; + justify-content: space-between; + + .o-onlyoffice-header-title { + column-gap: 10px; + } + } + + .o-onlyoffice-preview-body { + .o-onlyoffice-body-iframe { + width: 100%; + height: 100%; + border: none; + } + } +} diff --git a/addons_extensions/onlyoffice_odoo/static/src/css/res_config_settings_views.scss b/addons_extensions/onlyoffice_odoo/static/src/css/res_config_settings_views.scss new file mode 100644 index 000000000..4373719b5 --- /dev/null +++ b/addons_extensions/onlyoffice_odoo/static/src/css/res_config_settings_views.scss @@ -0,0 +1,44 @@ +.onlyoffice_link_container { + width: 549px; + height: 116px; + position: relative; + border-left: 0; + background-image: url("/onlyoffice_odoo/static/src/css/onlyoffice_link_container.png"); + background-size: contain; + + .onlyoffice_link_button { + display: flex; + align-items: center; + justify-content: center; + position: absolute; + min-width: 93px; + max-width: 120px; + max-height: 40px; + padding: 5px 0; + background-color: #71639e; + color: white; + font-family: "SF UI Display", sans-serif; + text-align: center; + font-weight: 500; + border-radius: 3px; + top: 40.5px; + right: 14px; + } +} + +@media (max-width: 767px) { + .onlyoffice_link_container { + width: 345px; + height: 169px; + background-image: url("/onlyoffice_odoo/static/src/css/onlyoffice_link_container_mobile.png"); + + .onlyoffice_link_button { + width: 77px; + height: 34px; + bottom: 16px; + left: 20px; + top: unset; + right: unset; + } + } +} diff --git a/addons_extensions/onlyoffice_odoo/static/src/models/attachment_card_onlyoffice.js b/addons_extensions/onlyoffice_odoo/static/src/models/attachment_card_onlyoffice.js new file mode 100644 index 000000000..3dfd5edc3 --- /dev/null +++ b/addons_extensions/onlyoffice_odoo/static/src/models/attachment_card_onlyoffice.js @@ -0,0 +1,71 @@ +/** @odoo-module **/ + +/* + * + * (c) Copyright Ascensio System SIA 2024 + * + */ + +import { AttachmentList } from "@mail/core/common/attachment_list" +import { _t } from "@web/core/l10n/translation" +import { useService } from "@web/core/utils/hooks" +import { patch } from "@web/core/utils/patch" + +let formats = [] +const loadFormats = async () => { + try { + const data = await fetch("/onlyoffice_odoo/static/assets/document_formats/onlyoffice-docs-formats.json") + formats = await data.json() + } catch (error) { + console.error("Error loading formats data:", error) + } +} + +loadFormats() + +patch(AttachmentList.prototype, { + setup() { + super.setup(...arguments) + this.orm = useService("orm") + this.notification = useService("notification") + this.actionService = useService("action") + }, + // eslint-disable-next-line sort-keys + onlyofficeCanOpen(attachment) { + const format = formats.find((f) => f.name === attachment.extension.toLowerCase()) + return format && format.actions && (format.actions.includes("view") || format.actions.includes("edit")) + }, + async openOnlyoffice(attachment) { + const demo = JSON.parse(await this.orm.call("onlyoffice.odoo", "get_demo")) + if (demo && demo.mode && demo.date) { + const isValidDate = (d) => d instanceof Date && !isNaN(d) + demo.date = new Date(Date.parse(demo.date)) + if (isValidDate(demo.date)) { + const today = new Date() + const difference = Math.floor((today - demo.date) / (1000 * 60 * 60 * 24)) + if (difference > 30) { + this.notification.add( + _t("The 30-day test period is over, you can no longer connect to demo ONLYOFFICE Docs server"), + { + title: _t("ONLYOFFICE Docs server"), + type: "warning", + }, + ) + return + } + } + } + const { same_tab } = JSON.parse(await this.orm.call("onlyoffice.odoo", "get_same_tab")) + if (same_tab) { + const action = { + params: { attachment_id: attachment.id }, + tag: "onlyoffice_editor", + target: "current", + type: "ir.actions.client", + } + return this.actionService.doAction(action) + } + const accessTokenQuery = attachment.accessToken ? `?access_token=${attachment.accessToken}` : "" + window.open(`/onlyoffice/editor/${attachment.id}${accessTokenQuery}`, "_blank") + }, +}) diff --git a/addons_extensions/onlyoffice_odoo/static/src/views/form_gallery/form_gallery.js b/addons_extensions/onlyoffice_odoo/static/src/views/form_gallery/form_gallery.js new file mode 100644 index 000000000..4a7fc5452 --- /dev/null +++ b/addons_extensions/onlyoffice_odoo/static/src/views/form_gallery/form_gallery.js @@ -0,0 +1,274 @@ +/** @odoo-module **/ +import { OnlyofficePreview } from "@onlyoffice_odoo/views/preview/onlyoffice_preview" +import { Dialog } from "@web/core/dialog/dialog" +import { Dropdown } from "@web/core/dropdown/dropdown" +import { DropdownItem } from "@web/core/dropdown/dropdown_item" +import { _t } from "@web/core/l10n/translation" +import { rpc } from "@web/core/network/rpc" +import { Pager } from "@web/core/pager/pager" +import { useService } from "@web/core/utils/hooks" + +const { Component, useState, onWillStart, onWillUnmount } = owl + +export class FormGallery extends Component { + static template = "onlyoffice_odoo.FormGallery" + + static components = { + Dialog, + Dropdown, + DropdownItem, + Pager, + } + + setup() { + this.title = _t("Document templates") + this.action = useService("action") + this.notification = useService("notification") + this.rpc = rpc + this.orm = useService("orm") + + this.searchTimeout = null + + this.state = useState({ + categories: [], + error: null, + form: null, + forms: [], + ghost: 0, + limit: 12, + loading: false, + locale: { + code: "en", + name: "English", + }, + locales: [ + { + code: "en", + name: "English", + }, + ], + offset: 0, + search: "", + subcategories: {}, + subcategory: { + category_type: "category", + id: "all", + }, + total: 0, + type: "pdf", + }) + + onWillStart(async () => { + this.state.loading = true + await this.fetchLocales() + await this.fetchCategoryTypes() + await this.fetchOforms() + this.state.loading = false + }) + + onWillUnmount(() => { + if (this.searchTimeout) { + clearTimeout(this.searchTimeout) + } + }) + } + + async fetchLocales() { + try { + const url = "/onlyoffice/oforms/locales" + const response = await this.rpc(url) + + let localesData = [] + if (Array.isArray(response)) { + localesData = response.map((item) => ({ + code: item.code, + name: item.name || item.code, + })) + } else if (response && response.data) { + localesData = response.data + } + + this.state.locales = localesData + } catch (_error) { + this.state.locales = [ + { + code: "en", + name: "English", + }, + ] + } + } + + async fetchCategoryTypes() { + try { + const response = await this.rpc("/onlyoffice/oforms/category-types", { locale: this.state.locale.code }) + this.state.categories = response.data || [] + for (const categoryTypes of response.data) { + await this.fetchSubcategories(categoryTypes.categoryId) + } + } catch (_error) { + this.notification.add(_t("Failed to load categories"), { type: "danger" }) + } + } + + async fetchSubcategories(categoryId) { + try { + const category = this.state.categories.find((c) => c.categoryId === categoryId) + const response = await this.rpc("/onlyoffice/oforms/subcategories", { + category_type: category.type, + locale: this.state.locale.code, + }) + + this.state.subcategories[categoryId] = response.data || [] + } catch (_error) { + this.state.subcategories[categoryId] = [] + } + } + + async fetchOforms() { + this.state.loading = true + this.state.form = null + this.state.error = null + + try { + const params = { + ["filters[" + this.state.subcategory.category_type + "][$eq]"]: this.state.subcategory.id, + locale: this.state.locale.code, + "pagination[pageSize]": this.state.limit, + "pagination[page]": Math.floor(this.state.offset / this.state.limit) + 1, + type: this.state.type, + } + + if (this.state.search) { + params["filters[name_form][$containsi]"] = this.state.search + } + + const response = await this.rpc("/onlyoffice/oforms", { params: params }) + + this.state.forms = response.data || [] + + const oKanbanGhost = 4 - (this.state.forms.length % 4) + if (oKanbanGhost === 4) { + this.state.ghost = new Array(0).fill() + } else { + this.state.ghost = new Array(oKanbanGhost).fill() + } + + this.state.total = response.meta?.pagination?.total || 0 + } catch (_error) { + this.state.error = _t("Failed to load forms") + this.notification.add(_t("Error loading forms"), { type: "danger" }) + } + this.state.loading = false + } + + async onChangeType(type) { + this.state.type = type + this.state.subcategory = { + category_type: "category", + id: "all", + } + this.state.offset = 0 + await this.fetchOforms() + } + + async onSubcategorySelect(subcategory) { + this.state.subcategory = subcategory + this.state.type = "pdf" + this.state.offset = 0 + await this.fetchOforms() + } + + async onAllSubcategorySelect() { + this.state.subcategory = { + category_type: "category", + id: "all", + } + this.state.offset = 0 + await this.fetchOforms() + } + + async onSearch(search) { + if (this.searchTimeout) { + clearTimeout(this.searchTimeout) + } + + this.state.search = search + + this.searchTimeout = setTimeout(async () => { + this.state.offset = 0 + await this.fetchOforms() + }, 1000) + } + + async onLocaleChange(locale) { + this.state.loading = true + + this.state.locale = locale + this.state.subcategory = { + category_type: "category", + id: "all", + } + this.state.offset = 0 + await this.fetchCategoryTypes() + await this.fetchOforms() + + this.state.loading = false + } + + async onPageChange({ offset }) { + this.state.offset = offset + await this.fetchOforms() + } + + getImageUrl(form) { + const imageData = form.attributes?.template_image?.data + if (!imageData) { + return null + } + return ( + imageData.attributes.formats.medium?.url || + imageData.attributes.formats.small?.url || + imageData.attributes.formats.thumbnail?.url + ) + } + + getPreviewUrl(form) { + return form.attributes?.card_prewiew?.data?.attributes?.url + } + + previewForm(url, title, ext) { + this.env.services.dialog.add( + OnlyofficePreview, + { + close: () => { + this.env.services.dialog.close() + }, + title: title + "." + ext.split(".").pop(), + url: url, + }, + { + onClose: () => { + return + }, + }, + ) + } + + selectForm(form) { + if (this.state.form && this.state.form.id === form.id) { + this.state.form = null + } else { + this.state.form = form + } + } + + async download() { + if (this.props.onDownload && this.state.form) { + await this.props.onDownload(this.state.form) + if (this.props.close) { + this.props.close() + } + } + } +} diff --git a/addons_extensions/onlyoffice_odoo/static/src/views/form_gallery/form_gallery.xml b/addons_extensions/onlyoffice_odoo/static/src/views/form_gallery/form_gallery.xml new file mode 100644 index 000000000..74c9d4de1 --- /dev/null +++ b/addons_extensions/onlyoffice_odoo/static/src/views/form_gallery/form_gallery.xml @@ -0,0 +1,174 @@ + + + + +
+
+
+ +
+
+
+
+ + + + + View all templates + + + +
+ + + + + Form + + + Document + + + Spreadsheet + + + Presentation + + + +
+
+
+ + + + + + + + + + +
+
+ +
+
+ + +
+ +

Loading forms...

+
+
+ + +
+ + +
+ +
+
+ +
+
+ +
+
+
+
+ + +
+
+
+
+
+ +
+ +
+ + +
+ +

No forms found

+

Try changing your search or filters

+
+
+
+ + + + + +
+
+
diff --git a/addons_extensions/onlyoffice_odoo/static/src/views/preview/onlyoffice_preview.js b/addons_extensions/onlyoffice_odoo/static/src/views/preview/onlyoffice_preview.js new file mode 100644 index 000000000..aa5602376 --- /dev/null +++ b/addons_extensions/onlyoffice_odoo/static/src/views/preview/onlyoffice_preview.js @@ -0,0 +1,51 @@ +/** @odoo-module **/ + +import { Component, onMounted, onWillUnmount } from "@odoo/owl" + +export class OnlyofficePreview extends Component { + static template = "onlyoffice_odoo.OnlyofficePreview" + + static props = { + close: Function, + title: String, + url: String, + } + + setup() { + this.title = "Preview - " + this.props.title + this.url = + "/onlyoffice/preview" + + `?url=${encodeURIComponent(this.props.url)}&` + + `title=${encodeURIComponent(this.props.title)}` + + const handleKeyDown = (ev) => { + if (ev.key === "Escape") { + ev.stopPropagation() + ev.preventDefault() + this.props.close() + } + } + + onMounted(() => { + document.addEventListener("keydown", handleKeyDown, { capture: true }) + document.querySelectorAll(".o-overlay-item").forEach((item) => { + if (item.querySelector(".o-onlyoffice-preview")) { + item.classList.add("o-onlyoffice-overlay-item") + } + }) + }) + + onWillUnmount(() => { + document.removeEventListener("keydown", handleKeyDown, { capture: true }) + }) + } + + onClickOutside(ev) { + const isHeader = ev.target.closest(".o-onlyoffice-preview-header") + const isBody = ev.target.closest(".o-onlyoffice-preview-body") + + if (!isHeader && !isBody) { + this.props.close() + } + } +} diff --git a/addons_extensions/onlyoffice_odoo/static/src/views/preview/onlyoffice_preview.xml b/addons_extensions/onlyoffice_odoo/static/src/views/preview/onlyoffice_preview.xml new file mode 100644 index 000000000..a76374966 --- /dev/null +++ b/addons_extensions/onlyoffice_odoo/static/src/views/preview/onlyoffice_preview.xml @@ -0,0 +1,19 @@ + + + +
+
+
+ +
+
+
+ +
+
+
+ + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + Download + +
+
+ + + + + + +
+ + +

+ This file does not Support Preview, Kindly Download Instead. +

+
+ + + + diff --git a/addons_extensions/universal_attachment_preview/static/src/css/attachment_preview.css b/addons_extensions/universal_attachment_preview/static/src/css/attachment_preview.css new file mode 100644 index 000000000..a358f9b05 --- /dev/null +++ b/addons_extensions/universal_attachment_preview/static/src/css/attachment_preview.css @@ -0,0 +1,30 @@ +/* Make dialog fill most of the screen */ +.o_dialog_container.o_fullscreen_preview { + width: 95vw !important; + max-width: 95vw !important; + height: 90vh !important; +} + +/* Remove padding from body */ +.o_fullscreen_preview .modal-body { + padding: 0 !important; + height: calc(90vh - 50px); /* subtract header height */ +} +/* Ensure wrapper fills height */ +.o_fullscreen_preview_body { + height: 100%; + width: 100%; +} + +/* Full-size iframe */ +.o_preview_frame { + width: 100%; + height: 100%; + border: none; +} + +.o_fullscreen_preview iframe { + width: 100%; + height: 100%; + border: none; +} diff --git a/addons_extensions/universal_attachment_preview/static/src/js/binary_file_preview.js b/addons_extensions/universal_attachment_preview/static/src/js/binary_file_preview.js new file mode 100644 index 000000000..3a866df1c --- /dev/null +++ b/addons_extensions/universal_attachment_preview/static/src/js/binary_file_preview.js @@ -0,0 +1,61 @@ +/** @odoo-module **/ + +import { BinaryField, ListBinaryField } from "@web/views/fields/binary/binary_field"; +import { patch } from "@web/core/utils/patch"; +import { useService } from "@web/core/utils/hooks"; +import { AttachmentPreviewPopup } from "@universal_attachment_preview/attachment_preview_popup/attachment_preview_popup"; +import { rpc } from "@web/core/network/rpc"; + +patch(BinaryField.prototype, { + setup() { + super.setup(); + this.dialog = useService("dialog"); + this.rpc = rpc; + }, + + async onPreviewFile(ev) { + debugger; + ev.stopPropagation(); + ev.preventDefault(); + + const data = this.getDownloadData(); + if (!data || !data.model || !data.id || !data.field) { + return; + } + + // 1️⃣ Convert Binary → Attachment (or fetch existing) + const result = await this.rpc("/lookup_or_create/attachment", { + model: data.model, + res_id: data.id, + field: data.field, + }); + + if (!result || !result.attachment_id) { + alert("Could not generate preview file."); + return; + } + + // 📄 Correct file name + let filename = data.filename; + if (result.name && !/^file($|[_\.])/i.test(result.name)) { + filename = result.name; + } + + + // 2️⃣ Open preview dialog with attachment details + this.dialog.add(AttachmentPreviewPopup, { + attachmentId: result.attachment_id, + filename: filename, + mimetype: result.mimetype, + url: result.url, + dialogClass: "o_fullscreen_preview", + }); + }, +}); + +// Patch list view binary +patch(ListBinaryField.prototype, { + onPreviewFile(ev) { + return BinaryField.prototype.onPreviewFile.call(this, ev); + }, +}); diff --git a/addons_extensions/universal_attachment_preview/static/src/js/binary_preview.js b/addons_extensions/universal_attachment_preview/static/src/js/binary_preview.js new file mode 100644 index 000000000..55ec5ca1e --- /dev/null +++ b/addons_extensions/universal_attachment_preview/static/src/js/binary_preview.js @@ -0,0 +1,263 @@ +/** @odoo-module **/ + +import { BinaryField, ListBinaryField } from "@web/views/fields/binary/binary_field"; +import { patch } from "@web/core/utils/patch"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { rpc } from "@web/core/network/rpc"; +import { Component } from "@odoo/owl"; + +// --------------------------- +// PREVIEW COMPONENT +// --------------------------- +export class PreviewContent extends Component { + setup() { + console.log("[PreviewContent] Setup called with props:", this.props); + // Log the actual props we need + console.log("[PreviewContent] URL:", this.props.url); + console.log("[PreviewContent] Direct URL:", this.props.directUrl); + console.log("[PreviewContent] Absolute URL:", this.getAbsoluteUrl()); + console.log("[PreviewContent] Filename:", this.props.filename); + console.log("[PreviewContent] Mimetype:", this.props.mimetype); + } + + static template = owl.xml/* xml */` +
+ + + + + + + + + + + + + + + + + +
+ +

Preview Not Available

+

This file type cannot be previewed directly.

+

Mimetype:

+ + Download File + +
+
+
+ `; + + static props = { + url: String, + directUrl: String, + filename: String, + mimetype: { type: String, optional: true }, + }; + + static defaultProps = { + mimetype: "", + }; + + get isImage() { + console.log("[PreviewContent] Checking if image, mimetype:", this.props.mimetype); + const result = this.props.mimetype && this.props.mimetype.startsWith("image/"); + console.log("[PreviewContent] isImage result:", result); + return result; + } + + get isPDF() { + console.log("[PreviewContent] Checking if PDF, mimetype:", this.props.mimetype); + const result = this.props.mimetype === "application/pdf"; + console.log("[PreviewContent] isPDF result:", result); + return result; + } + + get isVideo() { + console.log("[PreviewContent] Checking if video, mimetype:", this.props.mimetype); + const result = this.props.mimetype && this.props.mimetype.startsWith("video/"); + console.log("[PreviewContent] isVideo result:", result); + return result; + } + + get isDocument() { + console.log("[PreviewContent] Checking if document, mimetype:", this.props.mimetype); + const docTypes = [ + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'text/plain' + ]; + const result = docTypes.includes(this.props.mimetype); + console.log("[PreviewContent] isDocument result:", result); + return result; + } + + get isOther() { + console.log("[PreviewContent] Checking if other type"); + const result = !this.isImage && !this.isPDF && !this.isVideo && !this.isDocument; + console.log("[PreviewContent] isOther result:", result); + return result; + } + + getAbsoluteUrl() { + // Convert relative URL to absolute URL + const absoluteUrl = window.location.origin + this.props.directUrl; + console.log("[PreviewContent] Absolute URL:", absoluteUrl); + return absoluteUrl; + } + + getGoogleDocsUrl() { + const absoluteUrl = this.getAbsoluteUrl(); + const url = `https://docs.google.com/viewer?url=${encodeURIComponent(absoluteUrl)}&embedded=true`; + console.log("[PreviewContent] Google Docs URL:", url); + return url; + } +} + +// REGISTER COMPONENT +console.log("[DEBUG] Registering PreviewContent component"); +registry.category("components").add("PreviewContent", PreviewContent); +console.log("[DEBUG] PreviewContent component registered successfully"); + +// Create a wrapper component for the dialog +class PreviewDialog extends Component { + setup() { + console.log("[PreviewDialog] Setup called with props:", this.props); + // Extract the actual file props from the dialog options + this.fileProps = this.props.props || {}; + console.log("[PreviewDialog] Extracted file props:", this.fileProps); + } + + static template = owl.xml/* xml */` + + `; + static components = { PreviewContent }; + static props = { + title: String, + props: Object, + size: String, + close: Function, + }; +} + +// Register the wrapper component +console.log("[DEBUG] Registering PreviewDialog component"); +registry.category("components").add("PreviewDialog", PreviewDialog); +console.log("[DEBUG] PreviewDialog component registered successfully"); + +// --------------------------- +// PATCH BINARY FIELD +// --------------------------- +console.log("[DEBUG] Patching BinaryField"); +patch(BinaryField.prototype, { + setup() { + console.log("[BinaryField] Setup called"); + super.setup(); + this.rpc = rpc; + this.dialog = useService("dialog"); + console.log("[BinaryField] Services initialized:", { rpc: !!this.rpc, dialog: !!this.dialog }); + }, + + async onPreviewFile(ev) { + console.log("[BinaryField] onPreviewFile triggered"); + ev.stopPropagation(); + + const data = this.getDownloadData(); + console.log("[BinaryField] Download data:", data); + + // Check if we have the required data + if (!data || !data.model || !data.id || !data.field) { + console.error("[BinaryField] Missing required data:", data); + alert("Error: Missing file information"); + return; + } + + // Construct the correct URL for the file content + const directUrl = `/web/content?model=${data.model}&id=${data.id}&field=${data.field}&filename=${encodeURIComponent(data.filename || '')}`; + console.log("[BinaryField] Direct URL constructed:", directUrl); + + // Create a blob URL from the file content + try { + console.log("[BinaryField] Fetching file..."); + const response = await fetch(directUrl); + console.log("[BinaryField] Response status:", response.status); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const blob = await response.blob(); + console.log("[BinaryField] Blob created, size:", blob.size, "type:", blob.type); + + const url = URL.createObjectURL(blob); + console.log("[BinaryField] Object URL created:", url); + + // Prepare the props for the PreviewContent component + const previewProps = { + url, // Blob URL for direct preview in the browser + directUrl, // Direct server URL for Google Docs Viewer and PDF + filename: data.filename || 'Unknown', + mimetype: blob.type || data.mimetype || 'application/octet-stream' + }; + + console.log("[BinaryField] Preview props:", previewProps); + console.log("[BinaryField] About to open dialog..."); + + // Use the wrapper component class directly + this.dialog.add(PreviewDialog, { + title: `Preview: ${data.filename || 'File'}`, + props: previewProps, + size: "extra-large", + technical: false, // Ensure the dialog is visible + }); + console.log("[BinaryField] Dialog opened successfully"); + } catch (error) { + console.error("[BinaryField] Error fetching file:", error); + alert("Could not preview the file. Please try downloading it instead."); + // Fallback to download + console.log("[BinaryField] Falling back to download"); + window.open(directUrl + '&download=true', '_blank'); + } + }, + + _base64ToBlob(base64Data, mimeType) { + console.log("[BinaryField] Converting base64 to blob, type:", mimeType); + const byteCharacters = atob(base64Data); + const byteArrays = []; + for (let offset = 0; offset < byteCharacters.length; offset += 512) { + const slice = byteCharacters.slice(offset, offset + 512); + const byteNumbers = new Array(slice.length); + for (let i = 0; i < slice.length; i++) { + byteNumbers[i] = slice.charCodeAt(i); + } + byteArrays.push(new Uint8Array(byteNumbers)); + } + const blob = new Blob(byteArrays, { type: mimeType }); + console.log("[BinaryField] Blob created from base64, size:", blob.size); + return blob; + }, +}); + +console.log("[DEBUG] BinaryField patched successfully"); + +console.log("[DEBUG] Patching ListBinaryField"); +patch(ListBinaryField.prototype, { + onPreviewFile(ev) { + console.log("[ListBinaryField] onPreviewFile triggered, delegating to BinaryField"); + return BinaryField.prototype.onPreviewFile.call(this, ev); + }, +}); +console.log("[DEBUG] ListBinaryField patched successfully"); \ No newline at end of file diff --git a/addons_extensions/universal_attachment_preview/static/src/xml/binary_field_inherit.xml b/addons_extensions/universal_attachment_preview/static/src/xml/binary_field_inherit.xml new file mode 100644 index 000000000..e72f51820 --- /dev/null +++ b/addons_extensions/universal_attachment_preview/static/src/xml/binary_field_inherit.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons_extensions/universal_attachment_preview/static/src/xml/binary_file_preview.xml b/addons_extensions/universal_attachment_preview/static/src/xml/binary_file_preview.xml new file mode 100644 index 000000000..071afb6d1 --- /dev/null +++ b/addons_extensions/universal_attachment_preview/static/src/xml/binary_file_preview.xml @@ -0,0 +1,9 @@ + + + +
+

Hello World

+

Popup loaded successfully!

+
+
+
diff --git a/addons_extensions/universal_attachment_preview/static/src/xml/binary_preview_template.xml b/addons_extensions/universal_attachment_preview/static/src/xml/binary_preview_template.xml new file mode 100644 index 000000000..7e709e72e --- /dev/null +++ b/addons_extensions/universal_attachment_preview/static/src/xml/binary_preview_template.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + +
+

Preview not available. Download the file to view it locally.

+
+
+
+ +