1# -*- coding: utf-8 -*- 2# Part of Odoo. See LICENSE file for full copyright and licensing details. 3 4import logging 5import os 6import uuid 7import werkzeug 8 9from odoo import api, fields, models 10from odoo import tools 11from odoo.addons import website 12from odoo.exceptions import AccessError 13from odoo.osv import expression 14from odoo.http import request 15 16_logger = logging.getLogger(__name__) 17 18 19class View(models.Model): 20 21 _name = "ir.ui.view" 22 _inherit = ["ir.ui.view", "website.seo.metadata"] 23 24 website_id = fields.Many2one('website', ondelete='cascade', string="Website") 25 page_ids = fields.One2many('website.page', 'view_id') 26 first_page_id = fields.Many2one('website.page', string='Website Page', help='First page linked to this view', compute='_compute_first_page_id') 27 track = fields.Boolean(string='Track', default=False, help="Allow to specify for one page of the website to be trackable or not") 28 visibility = fields.Selection([('', 'All'), ('connected', 'Signed In'), ('restricted_group', 'Restricted Group'), ('password', 'With Password')], default='') 29 visibility_password = fields.Char(groups='base.group_system', copy=False) 30 visibility_password_display = fields.Char(compute='_get_pwd', inverse='_set_pwd', groups='website.group_website_designer') 31 32 @api.depends('visibility_password') 33 def _get_pwd(self): 34 for r in self: 35 r.visibility_password_display = r.sudo().visibility_password and '********' or '' 36 37 def _set_pwd(self): 38 crypt_context = self.env.user._crypt_context() 39 for r in self: 40 if r.type == 'qweb': 41 r.sudo().visibility_password = r.visibility_password_display and crypt_context.encrypt(r.visibility_password_display) or '' 42 r.visibility = r.visibility # double check access 43 44 def _compute_first_page_id(self): 45 for view in self: 46 view.first_page_id = self.env['website.page'].search([('view_id', '=', view.id)], limit=1) 47 48 def name_get(self): 49 if (not self._context.get('display_website') and not self.env.user.has_group('website.group_multi_website')) or \ 50 not self._context.get('display_website'): 51 return super(View, self).name_get() 52 53 res = [] 54 for view in self: 55 view_name = view.name 56 if view.website_id: 57 view_name += ' [%s]' % view.website_id.name 58 res.append((view.id, view_name)) 59 return res 60 61 def write(self, vals): 62 '''COW for ir.ui.view. This way editing websites does not impact other 63 websites. Also this way newly created websites will only 64 contain the default views. 65 ''' 66 current_website_id = self.env.context.get('website_id') 67 if not current_website_id or self.env.context.get('no_cow'): 68 return super(View, self).write(vals) 69 70 # We need to consider inactive views when handling multi-website cow 71 # feature (to copy inactive children views, to search for specific 72 # views, ...) 73 for view in self.with_context(active_test=False): 74 # Make sure views which are written in a website context receive 75 # a value for their 'key' field 76 if not view.key and not vals.get('key'): 77 view.with_context(no_cow=True).key = 'website.key_%s' % str(uuid.uuid4())[:6] 78 79 # No need of COW if the view is already specific 80 if view.website_id: 81 super(View, view).write(vals) 82 continue 83 84 # Ensure the cache of the pages stay consistent when doing COW. 85 # This is necessary when writing view fields from a page record 86 # because the generic page will put the given values on its cache 87 # but in reality the values were only meant to go on the specific 88 # page. Invalidate all fields and not only those in vals because 89 # other fields could have been changed implicitly too. 90 pages = view.page_ids 91 pages.flush(records=pages) 92 pages.invalidate_cache(ids=pages.ids) 93 94 # If already a specific view for this generic view, write on it 95 website_specific_view = view.search([ 96 ('key', '=', view.key), 97 ('website_id', '=', current_website_id) 98 ], limit=1) 99 if website_specific_view: 100 super(View, website_specific_view).write(vals) 101 continue 102 103 # Set key to avoid copy() to generate an unique key as we want the 104 # specific view to have the same key 105 copy_vals = {'website_id': current_website_id, 'key': view.key} 106 # Copy with the 'inherit_id' field value that will be written to 107 # ensure the copied view's validation works 108 if vals.get('inherit_id'): 109 copy_vals['inherit_id'] = vals['inherit_id'] 110 website_specific_view = view.copy(copy_vals) 111 112 view._create_website_specific_pages_for_view(website_specific_view, 113 view.env['website'].browse(current_website_id)) 114 115 for inherit_child in view.inherit_children_ids.filter_duplicate().sorted(key=lambda v: (v.priority, v.id)): 116 if inherit_child.website_id.id == current_website_id: 117 # In the case the child was already specific to the current 118 # website, we cannot just reattach it to the new specific 119 # parent: we have to copy it there and remove it from the 120 # original tree. Indeed, the order of children 'id' fields 121 # must remain the same so that the inheritance is applied 122 # in the same order in the copied tree. 123 child = inherit_child.copy({'inherit_id': website_specific_view.id, 'key': inherit_child.key}) 124 inherit_child.inherit_children_ids.write({'inherit_id': child.id}) 125 inherit_child.unlink() 126 else: 127 # Trigger COW on inheriting views 128 inherit_child.write({'inherit_id': website_specific_view.id}) 129 130 super(View, website_specific_view).write(vals) 131 132 return True 133 134 def _load_records_write_on_cow(self, cow_view, inherit_id, values): 135 inherit_id = self.search([ 136 ('key', '=', self.browse(inherit_id).key), 137 ('website_id', 'in', (False, cow_view.website_id.id)), 138 ], order='website_id', limit=1).id 139 values['inherit_id'] = inherit_id 140 cow_view.with_context(no_cow=True).write(values) 141 142 def _create_all_specific_views(self, processed_modules): 143 """ When creating a generic child view, we should 144 also create that view under specific view trees (COW'd). 145 Top level view (no inherit_id) do not need that behavior as they 146 will be shared between websites since there is no specific yet. 147 """ 148 # Only for the modules being processed 149 regex = '^(%s)[.]' % '|'.join(processed_modules) 150 # Retrieve the views through a SQl query to avoid ORM queries inside of for loop 151 # Retrieves all the views that are missing their specific counterpart with all the 152 # specific view parent id and their website id in one query 153 query = """ 154 SELECT generic.id, ARRAY[array_agg(spec_parent.id), array_agg(spec_parent.website_id)] 155 FROM ir_ui_view generic 156 INNER JOIN ir_ui_view generic_parent ON generic_parent.id = generic.inherit_id 157 INNER JOIN ir_ui_view spec_parent ON spec_parent.key = generic_parent.key 158 LEFT JOIN ir_ui_view specific ON specific.key = generic.key AND specific.website_id = spec_parent.website_id 159 WHERE generic.type='qweb' 160 AND generic.website_id IS NULL 161 AND generic.key ~ %s 162 AND spec_parent.website_id IS NOT NULL 163 AND specific.id IS NULL 164 GROUP BY generic.id 165 """ 166 self.env.cr.execute(query, (regex, )) 167 result = dict(self.env.cr.fetchall()) 168 169 for record in self.browse(result.keys()): 170 specific_parent_view_ids, website_ids = result[record.id] 171 for specific_parent_view_id, website_id in zip(specific_parent_view_ids, website_ids): 172 record.with_context(website_id=website_id).write({ 173 'inherit_id': specific_parent_view_id, 174 }) 175 super(View, self)._create_all_specific_views(processed_modules) 176 177 def unlink(self): 178 '''This implements COU (copy-on-unlink). When deleting a generic page 179 website-specific pages will be created so only the current 180 website is affected. 181 ''' 182 current_website_id = self._context.get('website_id') 183 184 if current_website_id and not self._context.get('no_cow'): 185 for view in self.filtered(lambda view: not view.website_id): 186 for w in self.env['website'].search([('id', '!=', current_website_id)]): 187 # reuse the COW mechanism to create 188 # website-specific copies, it will take 189 # care of creating pages and menus. 190 view.with_context(website_id=w.id).write({'name': view.name}) 191 192 specific_views = self.env['ir.ui.view'] 193 if self and self.pool._init: 194 for view in self.filtered(lambda view: not view.website_id): 195 specific_views += view._get_specific_views() 196 197 result = super(View, self + specific_views).unlink() 198 self.clear_caches() 199 return result 200 201 def _create_website_specific_pages_for_view(self, new_view, website): 202 for page in self.page_ids: 203 # create new pages for this view 204 new_page = page.copy({ 205 'view_id': new_view.id, 206 'is_published': page.is_published, 207 }) 208 page.menu_ids.filtered(lambda m: m.website_id.id == website.id).page_id = new_page.id 209 210 @api.model 211 def get_related_views(self, key, bundles=False): 212 '''Make this only return most specific views for website.''' 213 # get_related_views can be called through website=False routes 214 # (e.g. /web_editor/get_assets_editor_resources), so website 215 # dispatch_parameters may not be added. Manually set 216 # website_id. (It will then always fallback on a website, this 217 # method should never be called in a generic context, even for 218 # tests) 219 self = self.with_context(website_id=self.env['website'].get_current_website().id) 220 return super(View, self).get_related_views(key, bundles=bundles) 221 222 def filter_duplicate(self): 223 """ Filter current recordset only keeping the most suitable view per distinct key. 224 Every non-accessible view will be removed from the set: 225 * In non website context, every view with a website will be removed 226 * In a website context, every view from another website 227 """ 228 current_website_id = self._context.get('website_id') 229 most_specific_views = self.env['ir.ui.view'] 230 if not current_website_id: 231 return self.filtered(lambda view: not view.website_id) 232 233 for view in self: 234 # specific view: add it if it's for the current website and ignore 235 # it if it's for another website 236 if view.website_id and view.website_id.id == current_website_id: 237 most_specific_views |= view 238 # generic view: add it only if, for the current website, there is no 239 # specific view for this view (based on the same `key` attribute) 240 elif not view.website_id and not any(view.key == view2.key and view2.website_id and view2.website_id.id == current_website_id for view2 in self): 241 most_specific_views |= view 242 243 return most_specific_views 244 245 @api.model 246 def _view_get_inherited_children(self, view): 247 extensions = super(View, self)._view_get_inherited_children(view) 248 return extensions.filter_duplicate() 249 250 @api.model 251 def _view_obj(self, view_id): 252 ''' Given an xml_id or a view_id, return the corresponding view record. 253 In case of website context, return the most specific one. 254 :param view_id: either a string xml_id or an integer view_id 255 :return: The view record or empty recordset 256 ''' 257 if isinstance(view_id, str) or isinstance(view_id, int): 258 return self.env['website'].viewref(view_id) 259 else: 260 # It can already be a view object when called by '_views_get()' that is calling '_view_obj' 261 # for it's inherit_children_ids, passing them directly as object record. (Note that it might 262 # be a view_id from another website but it will be filtered in 'get_related_views()') 263 return view_id if view_id._name == 'ir.ui.view' else self.env['ir.ui.view'] 264 265 @api.model 266 def _get_inheriting_views_arch_domain(self, model): 267 domain = super(View, self)._get_inheriting_views_arch_domain(model) 268 current_website = self.env['website'].browse(self._context.get('website_id')) 269 website_views_domain = current_website.website_domain() 270 # when rendering for the website we have to include inactive views 271 # we will prefer inactive website-specific views over active generic ones 272 if current_website: 273 domain = [leaf for leaf in domain if 'active' not in leaf] 274 return expression.AND([website_views_domain, domain]) 275 276 @api.model 277 def get_inheriting_views_arch(self, model): 278 if not self._context.get('website_id'): 279 return super(View, self).get_inheriting_views_arch(model) 280 281 views = super(View, self.with_context(active_test=False)).get_inheriting_views_arch(model) 282 # prefer inactive website-specific views over active generic ones 283 return views.filter_duplicate().filtered('active') 284 285 @api.model 286 def _get_filter_xmlid_query(self): 287 """This method add some specific view that do not have XML ID 288 """ 289 if not self._context.get('website_id'): 290 return super()._get_filter_xmlid_query() 291 else: 292 return """SELECT res_id 293 FROM ir_model_data 294 WHERE res_id IN %(res_ids)s 295 AND model = 'ir.ui.view' 296 AND module IN %(modules)s 297 UNION 298 SELECT sview.id 299 FROM ir_ui_view sview 300 INNER JOIN ir_ui_view oview USING (key) 301 INNER JOIN ir_model_data d 302 ON oview.id = d.res_id 303 AND d.model = 'ir.ui.view' 304 AND d.module IN %(modules)s 305 WHERE sview.id IN %(res_ids)s 306 AND sview.website_id IS NOT NULL 307 AND oview.website_id IS NULL; 308 """ 309 310 @api.model 311 @tools.ormcache_context('self.env.uid', 'self.env.su', 'xml_id', keys=('website_id',)) 312 def get_view_id(self, xml_id): 313 """If a website_id is in the context and the given xml_id is not an int 314 then try to get the id of the specific view for that website, but 315 fallback to the id of the generic view if there is no specific. 316 317 If no website_id is in the context, it might randomly return the generic 318 or the specific view, so it's probably not recommanded to use this 319 method. `viewref` is probably more suitable. 320 321 Archived views are ignored (unless the active_test context is set, but 322 then the ormcache_context will not work as expected). 323 """ 324 if 'website_id' in self._context and not isinstance(xml_id, int): 325 current_website = self.env['website'].browse(self._context.get('website_id')) 326 domain = ['&', ('key', '=', xml_id)] + current_website.website_domain() 327 328 view = self.sudo().search(domain, order='website_id', limit=1) 329 if not view: 330 _logger.warning("Could not find view object with xml_id '%s'", xml_id) 331 raise ValueError('View %r in website %r not found' % (xml_id, self._context['website_id'])) 332 return view.id 333 return super(View, self.sudo()).get_view_id(xml_id) 334 335 @api.model 336 def read_template(self, xml_id): 337 """ This method is deprecated 338 """ 339 view = self._view_obj(self.get_view_id(xml_id)) 340 if view.visibility and view._handle_visibility(do_raise=False): 341 self = self.sudo() 342 return super(View, self).read_template(xml_id) 343 344 def _get_original_view(self): 345 """Given a view, retrieve the original view it was COW'd from. 346 The given view might already be the original one. In that case it will 347 (and should) return itself. 348 """ 349 self.ensure_one() 350 domain = [('key', '=', self.key), ('model_data_id', '!=', None)] 351 return self.with_context(active_test=False).search(domain, limit=1) # Useless limit has multiple xmlid should not be possible 352 353 def _handle_visibility(self, do_raise=True): 354 """ Check the visibility set on the main view and raise 403 if you should not have access. 355 Order is: Public, Connected, Has group, Password 356 357 It only check the visibility on the main content, others views called stay available in rpc. 358 """ 359 error = False 360 361 self = self.sudo() 362 363 if self.visibility and not request.env.user.has_group('website.group_website_designer'): 364 if (self.visibility == 'connected' and request.website.is_public_user()): 365 error = werkzeug.exceptions.Forbidden() 366 elif self.visibility == 'password' and \ 367 (request.website.is_public_user() or self.id not in request.session.get('views_unlock', [])): 368 pwd = request.params.get('visibility_password') 369 if pwd and self.env.user._crypt_context().verify( 370 pwd, self.sudo().visibility_password): 371 request.session.setdefault('views_unlock', list()).append(self.id) 372 else: 373 error = werkzeug.exceptions.Forbidden('website_visibility_password_required') 374 375 if self.visibility not in ('password', 'connected'): 376 try: 377 self._check_view_access() 378 except AccessError: 379 error = werkzeug.exceptions.Forbidden() 380 381 if error: 382 if do_raise: 383 raise error 384 else: 385 return False 386 return True 387 388 def _render(self, values=None, engine='ir.qweb', minimal_qcontext=False): 389 """ Render the template. If website is enabled on request, then extend rendering context with website values. """ 390 self._handle_visibility(do_raise=True) 391 new_context = dict(self._context) 392 if request and getattr(request, 'is_frontend', False): 393 394 editable = request.website.is_publisher() 395 translatable = editable and self._context.get('lang') != request.website.default_lang_id.code 396 editable = not translatable and editable 397 398 # in edit mode ir.ui.view will tag nodes 399 if not translatable and not self.env.context.get('rendering_bundle'): 400 if editable: 401 new_context = dict(self._context, inherit_branding=True) 402 elif request.env.user.has_group('website.group_website_publisher'): 403 new_context = dict(self._context, inherit_branding_auto=True) 404 if values and 'main_object' in values: 405 if request.env.user.has_group('website.group_website_publisher'): 406 func = getattr(values['main_object'], 'get_backend_menu_id', False) 407 values['backend_menu_id'] = func and func() or self.env['ir.model.data'].xmlid_to_res_id('website.menu_website_configuration') 408 409 if self._context != new_context: 410 self = self.with_context(new_context) 411 return super(View, self)._render(values, engine=engine, minimal_qcontext=minimal_qcontext) 412 413 @api.model 414 def _prepare_qcontext(self): 415 """ Returns the qcontext : rendering context with website specific value (required 416 to render website layout template) 417 """ 418 qcontext = super(View, self)._prepare_qcontext() 419 420 if request and getattr(request, 'is_frontend', False): 421 Website = self.env['website'] 422 editable = request.website.is_publisher() 423 translatable = editable and self._context.get('lang') != request.env['ir.http']._get_default_lang().code 424 editable = not translatable and editable 425 426 cur = Website.get_current_website() 427 if self.env.user.has_group('website.group_website_publisher') and self.env.user.has_group('website.group_multi_website'): 428 qcontext['multi_website_websites_current'] = {'website_id': cur.id, 'name': cur.name, 'domain': cur._get_http_domain()} 429 qcontext['multi_website_websites'] = [ 430 {'website_id': website.id, 'name': website.name, 'domain': website._get_http_domain()} 431 for website in Website.search([]) if website != cur 432 ] 433 434 cur_company = self.env.company 435 qcontext['multi_website_companies_current'] = {'company_id': cur_company.id, 'name': cur_company.name} 436 qcontext['multi_website_companies'] = [ 437 {'company_id': comp.id, 'name': comp.name} 438 for comp in self.env.user.company_ids if comp != cur_company 439 ] 440 441 qcontext.update(dict( 442 main_object=self, 443 website=request.website, 444 is_view_active=request.website.is_view_active, 445 res_company=request.website.company_id.sudo(), 446 translatable=translatable, 447 editable=editable, 448 )) 449 450 return qcontext 451 452 @api.model 453 def get_default_lang_code(self): 454 website_id = self.env.context.get('website_id') 455 if website_id: 456 lang_code = self.env['website'].browse(website_id).default_lang_id.code 457 return lang_code 458 else: 459 return super(View, self).get_default_lang_code() 460 461 def redirect_to_page_manager(self): 462 return { 463 'type': 'ir.actions.act_url', 464 'url': '/website/pages', 465 'target': 'self', 466 } 467 468 def _read_template_keys(self): 469 return super(View, self)._read_template_keys() + ['website_id'] 470 471 @api.model 472 def _save_oe_structure_hook(self): 473 res = super(View, self)._save_oe_structure_hook() 474 res['website_id'] = self.env['website'].get_current_website().id 475 return res 476 477 @api.model 478 def _set_noupdate(self): 479 '''If website is installed, any call to `save` from the frontend will 480 actually write on the specific view (or create it if not exist yet). 481 In that case, we don't want to flag the generic view as noupdate. 482 ''' 483 if not self._context.get('website_id'): 484 super(View, self)._set_noupdate() 485 486 def save(self, value, xpath=None): 487 self.ensure_one() 488 current_website = self.env['website'].get_current_website() 489 # xpath condition is important to be sure we are editing a view and not 490 # a field as in that case `self` might not exist (check commit message) 491 if xpath and self.key and current_website: 492 # The first time a generic view is edited, if multiple editable parts 493 # were edited at the same time, multiple call to this method will be 494 # done but the first one may create a website specific view. So if there 495 # already is a website specific view, we need to divert the super to it. 496 website_specific_view = self.env['ir.ui.view'].search([ 497 ('key', '=', self.key), 498 ('website_id', '=', current_website.id) 499 ], limit=1) 500 if website_specific_view: 501 self = website_specific_view 502 super(View, self).save(value, xpath=xpath) 503 504 # -------------------------------------------------------------------------- 505 # Snippet saving 506 # -------------------------------------------------------------------------- 507 508 @api.model 509 def _snippet_save_view_values_hook(self): 510 res = super()._snippet_save_view_values_hook() 511 website_id = self.env.context.get('website_id') 512 if website_id: 513 res['website_id'] = website_id 514 return res 515