Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit cf8f314

Browse files
committedMar 21, 2025·
[IMP] super_portal: Implement Super Portal User access
Features: - Created a 'Super Portal User' group with extended portal access. - Introduced 'Edit Portal Access' for managing email and portal access. - Restricted 'Portal View' modifications to Sales Administrators. - Modified contact form: renamed 'Other Address' to 'Company Address,' ensured correct contact type. - Implemented transaction filtering by contact in the portal (Sales Orders, Invoices, POs, Helpdesk). - Established a super branch and branch hierarchy for super and sub branches. - Recomputing prices based on billing address pricelist.
1 parent 460af3f commit cf8f314

14 files changed

+670
-0
lines changed
 

‎super_portal/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from . import models
2+
from . import controllers

‎super_portal/__manifest__.py

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
'name': 'Super Portal User',
3+
'category': 'Portal',
4+
'summary': 'Portal access for multi-branch management',
5+
'depends': ['contacts', 'website_sale'],
6+
'data': [
7+
'security/portal_security.xml',
8+
'views/res_partner_views.xml',
9+
'views/portal_wizard_views.xml',
10+
'views/templates.xml',
11+
],
12+
'assets': {
13+
'web.assets_frontend': [
14+
'super_portal/static/src/js/website_sale.js',
15+
'super_portal/static/src/js/address_search.js'
16+
],
17+
},
18+
'license': 'LGPL-3',
19+
'installable': True,
20+
}

‎super_portal/controllers/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from . import main
2+
from . import portal

‎super_portal/controllers/main.py

+115
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
from odoo import http
2+
from odoo.http import request
3+
from odoo.addons.website_sale.controllers.main import WebsiteSale
4+
from werkzeug.exceptions import Forbidden
5+
6+
class WebsiteSalePortal(WebsiteSale):
7+
8+
#-------------------------------------------------------------------------------#
9+
# Arrange branches in order the display of address options available
10+
#-------------------------------------------------------------------------------#
11+
def checkout_values(self, order, **kw):
12+
order = order or request.website.sale_get_order(force_create=True)
13+
bill_partners = []
14+
ship_partners = []
15+
16+
if not order._is_public_order():
17+
Partner = order.partner_id.with_context(show_address=1).sudo()
18+
commercial_partner = order.partner_id.commercial_partner_id
19+
bill_partners = Partner.search([
20+
'|', ("type", "in", ["invoice", "other"]), ("id", "=", commercial_partner.id),
21+
("id", "child_of", commercial_partner.ids)
22+
], order='id asc, parent_id asc') | order.partner_id
23+
ship_partners = Partner.search([
24+
'|', ("type", "in", ["delivery", "other"]), ("id", "=", commercial_partner.id),
25+
("id", "child_of", commercial_partner.ids)
26+
], order='id asc, parent_id asc') | order.partner_id
27+
28+
if commercial_partner != order.partner_id:
29+
if not self._check_billing_partner_mandatory_fields(commercial_partner):
30+
bill_partners = bill_partners.filtered(lambda p: p.id != commercial_partner.id)
31+
if not self._check_shipping_partner_mandatory_fields(commercial_partner):
32+
ship_partners = ship_partners.filtered(lambda p: p.id != commercial_partner.id)
33+
34+
return {
35+
'order': order,
36+
'website_sale_order': order,
37+
'shippings': ship_partners,
38+
'billings': bill_partners,
39+
'only_services': order and order.only_services or False
40+
}
41+
42+
#----------------------------------------------------------------------------------------------#
43+
# Recompute the prices according to the price list associated with the selected billing address
44+
#----------------------------------------------------------------------------------------------#
45+
@http.route(
46+
'/shop/cart/update_address', type='http', auth='public',
47+
methods=['POST'], website=True, csrf=False
48+
)
49+
def update_cart_address(self, partner_id, mode='billing', **kw):
50+
response = super().update_cart_address(partner_id, mode, **kw)
51+
52+
order_sudo = request.website.sale_get_order()
53+
if not order_sudo:
54+
return response
55+
56+
partner_sudo = request.env['res.partner'].sudo().browse(int(partner_id)).exists()
57+
if not partner_sudo:
58+
raise Forbidden()
59+
60+
new_pricelist = partner_sudo.property_product_pricelist
61+
if new_pricelist and new_pricelist != order_sudo.pricelist_id:
62+
order_sudo.write({'pricelist_id': new_pricelist.id})
63+
64+
order_sudo._recompute_prices()
65+
order_sudo._compute_amounts()
66+
order_sudo.sudo().write({
67+
'amount_total': order_sudo.amount_total,
68+
'amount_tax': order_sudo.amount_tax,
69+
'amount_untaxed': order_sudo.amount_untaxed
70+
})
71+
72+
return response
73+
74+
@http.route(
75+
['/shop/cart/update_total'], type='json', auth='public',
76+
methods=['POST'], website=True, csrf=False
77+
)
78+
def cart_update_total(self):
79+
order_sudo = request.website.sale_get_order()
80+
if not order_sudo:
81+
return {"error": "No active order found"}
82+
83+
line_items = [
84+
{
85+
"unit_price": line.price_unit,
86+
"subtotal": line.price_subtotal,
87+
}
88+
for line in order_sudo.order_line
89+
]
90+
91+
return {
92+
"amount_untaxed": order_sudo.amount_untaxed,
93+
"amount_tax": order_sudo.amount_tax,
94+
"amount_total": order_sudo.amount_total,
95+
"cart_quantity": order_sudo.cart_quantity,
96+
"line_items": line_items,
97+
}
98+
99+
@http.route(['/shop/confirm_order'], type='http', auth="public", website=True, sitemap=False)
100+
def confirm_order(self, **post):
101+
order = request.website.sale_get_order()
102+
103+
redirection = self.checkout_redirection(order) or self.checkout_check_address(order)
104+
if redirection:
105+
return redirection
106+
107+
order.order_line._compute_tax_id()
108+
# Disable to update pricelist based on Website Pricelist
109+
110+
# request.website.sale_get_order(update_pricelist=True)
111+
extra_step = request.website.viewref('website_sale.extra_info')
112+
if extra_step.active:
113+
return request.redirect("/shop/extra_info")
114+
115+
return request.redirect("/shop/payment")

‎super_portal/controllers/portal.py

+277
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
from collections import OrderedDict
2+
from markupsafe import Markup
3+
from operator import itemgetter
4+
5+
from odoo import _, http
6+
from odoo.http import request
7+
from odoo.addons.portal.controllers.portal import CustomerPortal, pager as portal_pager
8+
from odoo.osv.expression import OR, AND
9+
from odoo.tools import groupby as groupbyelem
10+
11+
12+
class CustomPortal(CustomerPortal):
13+
14+
#-------------------------------------------------------------------------------#
15+
# Generalise filter based on super and sub branch
16+
#-------------------------------------------------------------------------------#
17+
def _get_searchbar_filters(self):
18+
partner = request.env.user.partner_id
19+
filters = OrderedDict({'all': {'label': _('All'), 'domain': []}})
20+
21+
partner_ids = request.env['res.partner'].search([
22+
('id', 'child_of', partner.id),
23+
('is_company', '=', True)
24+
])
25+
26+
for p in partner_ids:
27+
filters[str(p.id)] = {
28+
'label': p.name,
29+
'domain': [('partner_id', '=', p.id)],
30+
}
31+
return filters
32+
33+
#-------------------------------------------------------------------------------#
34+
# Add filters for super-branch and sub-branch in each section
35+
# (sales orders, your invoices, our orders, tickets)
36+
#-------------------------------------------------------------------------------#
37+
38+
#-------------------------------------------------------------------------------#
39+
# 1. invoices & bills
40+
def _get_account_searchbar_filters(self):
41+
branch_filters = self._get_searchbar_filters()
42+
filters = OrderedDict({
43+
'all': {'label': _('All'), 'domain': []},
44+
'invoices': {'label': _('Invoices'),
45+
'domain': [('move_type', 'in', ('out_invoice', 'out_refund', 'out_receipt'))]},
46+
'bills': {'label': _('Bills'),
47+
'domain': [('move_type', 'in', ('in_invoice', 'in_refund', 'in_receipt'))]},
48+
})
49+
filters.update(branch_filters)
50+
return filters
51+
52+
#-------------------------------------------------------------------------------#
53+
# 2. your orders
54+
def _get_sale_searchbar_filters(self):
55+
return self._get_searchbar_filters()
56+
57+
def _prepare_sale_portal_rendering_values(
58+
self, page=1, date_begin=None, date_end=None, sortby=None, filterby=None, quotation_page=False, **kwargs
59+
):
60+
SaleOrder = request.env['sale.order']
61+
62+
if not sortby:
63+
sortby = 'date'
64+
65+
partner = request.env.user.partner_id
66+
values = self._prepare_portal_layout_values()
67+
68+
if quotation_page:
69+
url = "/my/quotes"
70+
domain = self._prepare_quotations_domain(partner)
71+
else:
72+
url = "/my/orders"
73+
domain = self._prepare_orders_domain(partner)
74+
75+
searchbar_sortings = self._get_sale_searchbar_sortings()
76+
searchbar_filters = self._get_sale_searchbar_filters()
77+
if not filterby:
78+
filterby = 'all'
79+
domain += searchbar_filters[filterby]['domain']
80+
81+
sort_order = searchbar_sortings[sortby]['order']
82+
83+
if date_begin and date_end:
84+
domain += [('create_date', '>', date_begin), ('create_date', '<=', date_end)]
85+
86+
pager_values = portal_pager(
87+
url=url,
88+
total=SaleOrder.search_count(domain),
89+
page=page,
90+
step=self._items_per_page,
91+
url_args={'date_begin': date_begin, 'date_end': date_end, 'sortby': sortby, 'filterby': filterby},
92+
)
93+
orders = SaleOrder.search(domain, order=sort_order, limit=self._items_per_page, offset=pager_values['offset'])
94+
95+
values.update({
96+
'date': date_begin,
97+
'quotations': orders.sudo() if quotation_page else SaleOrder,
98+
'orders': orders.sudo() if not quotation_page else SaleOrder,
99+
'page_name': 'quote' if quotation_page else 'order',
100+
'pager': pager_values,
101+
'default_url': url,
102+
'searchbar_sortings': searchbar_sortings,
103+
'sortby': sortby,
104+
'searchbar_filters': searchbar_filters,
105+
'filterby': filterby
106+
})
107+
108+
return values
109+
110+
#-------------------------------------------------------------------------------#
111+
# 3. our orders
112+
@http.route(
113+
['/my/purchase', '/my/purchase/page/<int:page>'], type='http',
114+
auth="user", website=True
115+
)
116+
def portal_my_purchase_orders(
117+
self, page=1, date_begin=None, date_end=None, sortby=None, filterby=None, **kw
118+
):
119+
return self._render_portal(
120+
"purchase.portal_my_purchase_orders",
121+
page, date_begin, date_end, sortby, filterby,
122+
[],
123+
self._get_searchbar_filters(),
124+
'all',
125+
"/my/purchase",
126+
'my_purchases_history',
127+
'purchase',
128+
'orders'
129+
)
130+
131+
#-------------------------------------------------------------------------------#
132+
# 4. tickets
133+
def _prepare_my_tickets_values(
134+
self, page=1, date_begin=None, date_end=None, sortby=None, filterby='all',
135+
search=None, groupby='none', search_in='content'
136+
):
137+
138+
values = self._prepare_portal_layout_values()
139+
domain = self._prepare_helpdesk_tickets_domain()
140+
141+
searchbar_sortings = {
142+
'date': {'label': _('Newest'), 'order': 'create_date desc'},
143+
'reference': {'label': _('Reference'), 'order': 'id desc'},
144+
'name': {'label': _('Subject'), 'order': 'name'},
145+
'user': {'label': _('Assigned to'), 'order': 'user_id'},
146+
'stage': {'label': _('Stage'), 'order': 'stage_id'},
147+
'update': {'label': _('Last Stage Update'), 'order': 'date_last_stage_update desc'},
148+
}
149+
searchbar_filters = self._get_searchbar_filters()
150+
searchbar_inputs = {
151+
'content': {
152+
'input': 'content',
153+
'label': Markup(_('Search <span class="nolabel"> (in Content)</span>'))
154+
},
155+
'ticket_ref': {'input': 'ticket_ref', 'label': _('Search in Reference')},
156+
'message': {'input': 'message', 'label': _('Search in Messages')},
157+
'user': {'input': 'user', 'label': _('Search in Assigned to')},
158+
'status': {'input': 'status', 'label': _('Search in Stage')},
159+
}
160+
searchbar_groupby = {
161+
'none': {'input': 'none', 'label': _('None')},
162+
'stage': {'input': 'stage_id', 'label': _('Stage')},
163+
'user': {'input': 'user_id', 'label': _('Assigned to')},
164+
}
165+
166+
# default sort by value
167+
if not sortby:
168+
sortby = 'date'
169+
order = searchbar_sortings[sortby]['order']
170+
if groupby in searchbar_groupby and groupby != 'none':
171+
order = f'{searchbar_groupby[groupby]["input"]}, {order}'
172+
173+
if filterby in ['last_message_sup', 'last_message_cust']:
174+
discussion_subtype_id = request.env.ref('mail.mt_comment').id
175+
messages = request.env['mail.message'].search_read(
176+
[('model', '=', 'helpdesk.ticket'), ('subtype_id', '=', discussion_subtype_id)],
177+
fields=['res_id', 'author_id'], order='date desc'
178+
)
179+
180+
last_author_dict = {}
181+
for message in messages:
182+
if message['res_id'] not in last_author_dict:
183+
last_author_dict[message['res_id']] = message['author_id'][0]
184+
185+
ticket_author_list = request.env['helpdesk.ticket'].search_read(fields=['id', 'partner_id'])
186+
ticket_author_dict = {
187+
ticket['id']: ticket['partner_id'][0] if ticket['partner_id'] else False
188+
for ticket in ticket_author_list
189+
}
190+
191+
last_message_cust = []
192+
last_message_sup = []
193+
ticket_ids = set(last_author_dict.keys()) & set(ticket_author_dict.keys())
194+
for ticket_id in ticket_ids:
195+
if last_author_dict[ticket_id] == ticket_author_dict[ticket_id]:
196+
last_message_cust.append(ticket_id)
197+
else:
198+
last_message_sup.append(ticket_id)
199+
200+
if filterby == 'last_message_cust':
201+
domain = AND([domain, [('id', 'in', last_message_cust)]])
202+
else:
203+
domain = AND([domain, [('id', 'in', last_message_sup)]])
204+
205+
else:
206+
domain = AND([domain, searchbar_filters[filterby]['domain']])
207+
208+
if date_begin and date_end:
209+
domain = AND([domain, [('create_date', '>', date_begin), ('create_date', '<=', date_end)]])
210+
211+
# Search
212+
if search and search_in:
213+
search_domain = []
214+
if search_in == 'ticket_ref':
215+
search_domain = OR([search_domain, [('ticket_ref', 'ilike', search)]])
216+
elif search_in == 'content':
217+
search_domain = OR([
218+
search_domain, ['|', ('name', 'ilike', search), ('description', 'ilike', search)]
219+
])
220+
elif search_in == 'user':
221+
assignees = request.env['res.users'].sudo()._search([('name', 'ilike', search)])
222+
search_domain = OR([search_domain, [('user_id', 'in', assignees)]])
223+
elif search_in == 'message':
224+
discussion_subtype_id = request.env.ref('mail.mt_comment').id
225+
search_domain = OR([
226+
search_domain, [('message_ids.body', 'ilike', search),
227+
('message_ids.subtype_id', '=', discussion_subtype_id)]
228+
])
229+
elif search_in == 'status':
230+
search_domain = OR([search_domain, [('stage_id', 'ilike', search)]])
231+
232+
domain = AND([domain, search_domain])
233+
234+
# Pager
235+
tickets_count = request.env['helpdesk.ticket'].search_count(domain)
236+
pager = portal_pager(
237+
url="/my/tickets",
238+
url_args={
239+
'date_begin': date_begin, 'date_end': date_end, 'sortby': sortby,
240+
'search_in': search_in, 'search': search, 'groupby': groupby, 'filterby': filterby
241+
},
242+
total=tickets_count, page=page, step=self._items_per_page
243+
)
244+
245+
tickets = request.env['helpdesk.ticket'].search(
246+
domain, order=order, limit=self._items_per_page, offset=pager['offset']
247+
)
248+
request.session['my_tickets_history'] = tickets.ids[:100]
249+
250+
# Group tickets if needed
251+
if not tickets:
252+
grouped_tickets = []
253+
elif groupby != 'none':
254+
grouped_tickets = [
255+
request.env['helpdesk.ticket'].concat(*g)
256+
for k, g in groupbyelem(tickets, itemgetter(searchbar_groupby[groupby]['input']))
257+
]
258+
else:
259+
grouped_tickets = [tickets]
260+
261+
values.update({
262+
'date': date_begin,
263+
'grouped_tickets': grouped_tickets,
264+
'page_name': 'ticket',
265+
'default_url': '/my/tickets',
266+
'pager': pager,
267+
'searchbar_sortings': searchbar_sortings,
268+
'searchbar_filters': searchbar_filters,
269+
'searchbar_inputs': searchbar_inputs,
270+
'searchbar_groupby': searchbar_groupby,
271+
'sortby': sortby,
272+
'groupby': groupby,
273+
'search_in': search_in,
274+
'search': search,
275+
'filterby': filterby,
276+
})
277+
return values

‎super_portal/models/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from . import res_partner
2+
from . import portal_wizard

‎super_portal/models/portal_wizard.py

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from odoo import api, fields, models
2+
3+
4+
class PortalWizardUser(models.TransientModel):
5+
_inherit = 'portal.wizard.user'
6+
7+
can_edit = fields.Boolean(compute='_compute_can_edit', string="Can Edit")
8+
9+
#-------------------------------------------------------------------------------#
10+
# Compute [if the user can edit based on access rights]
11+
#-------------------------------------------------------------------------------#
12+
@api.depends('user_id', 'user_id.groups_id')
13+
def _compute_can_edit(self):
14+
15+
for portal_wizard_user in self:
16+
user = portal_wizard_user.user_id
17+
if user.has_group('super_portal.group_edit_portal_access'):
18+
portal_wizard_user.can_edit = True
19+
else:
20+
portal_wizard_user.can_edit = False

‎super_portal/models/res_partner.py

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from odoo import api, fields, models
2+
3+
4+
class ResPartner(models.Model):
5+
_inherit = 'res.partner'
6+
7+
type = fields.Selection([
8+
('contact', "Contact"),
9+
('invoice', "Invoice Address"),
10+
('delivery', "Delivery Address"),
11+
('other', "Company Address")
12+
], string="Address Type", default='contact')
13+
14+
@api.model
15+
def create(self, vals):
16+
if 'parent_id' in vals and vals['parent_id']:
17+
vals['is_company'] = True
18+
return super(ResPartner, self).create(vals)
+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<odoo>
2+
<record id="group_edit_portal_access" model="res.groups">
3+
<field name="name">Edit Portal Access</field>
4+
<field name="category_id" ref="base.module_category_usability"/>
5+
</record>
6+
7+
<!-- Restrict Editing of Portal View -->
8+
<record id="edit_portal_access_rule" model="ir.rule">
9+
<field name="name">Restrict Portal Access Editing</field>
10+
<field name="model_id" ref="portal.model_portal_wizard"/>
11+
<field name="groups" eval="[(4, ref('super_portal.group_edit_portal_access'))]"/>
12+
<field name="perm_read" eval="1"/>
13+
<field name="perm_write" eval="1"/>
14+
<field name="perm_create" eval="0"/>
15+
<field name="perm_unlink" eval="0"/>
16+
</record>
17+
</odoo>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/** @odoo-module **/
2+
3+
import publicWidget from "@web/legacy/js/public/public_widget";
4+
5+
function filterAddresses(inputElement, sectionClass) {
6+
let searchTerm = inputElement.value.toLowerCase();
7+
let addressTiles = document.querySelectorAll(`.${sectionClass} .one_kanban`);
8+
9+
addressTiles.forEach((tile) => {
10+
let textContent = tile.innerText.toLowerCase();
11+
let isSelected = tile.querySelector('.card')?.classList.contains('bg-primary');
12+
13+
tile.style.display = textContent.includes(searchTerm) || isSelected ? 'block' : 'none';
14+
});
15+
}
16+
window.filterAddresses = filterAddresses;
17+
18+
publicWidget.registry.AddressSearch = publicWidget.Widget.extend({
19+
selector: '.o_billing_address_search, .o_shipping_address_search',
20+
21+
events: {
22+
'keyup .o_billing_address_search': '_onKeyUpBilling',
23+
'keyup .o_shipping_address_search': '_onKeyUpShipping',
24+
},
25+
26+
_onKeyUpBilling: function (ev) {
27+
filterAddresses(ev.currentTarget, 'all_billing');
28+
},
29+
30+
_onKeyUpShipping: function (ev) {
31+
filterAddresses(ev.currentTarget, 'all_shipping');
32+
},
33+
});
+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/** @odoo-module **/
2+
3+
import publicWidget from "@web/legacy/js/public/public_widget";
4+
import { jsonrpc } from "@web/core/network/rpc_service";
5+
6+
publicWidget.registry.websiteSaleCart = publicWidget.registry.websiteSaleCart.extend({
7+
events: Object.assign({}, publicWidget.registry.websiteSaleCart.prototype.events, {
8+
'click .js_change_billing': '_onClickChangeBilling',
9+
}),
10+
11+
_onClickChangeBilling: function (ev) {
12+
var self = this;
13+
var rowAddrClass = "all_billing";
14+
var cardClass = "js_change_billing";
15+
16+
var $old = $(`.${rowAddrClass}`).find('.card.border.border-primary');
17+
$old.find('.btn-addr').toggle();
18+
$old.addClass(cardClass);
19+
$old.removeClass('bg-primary border border-primary');
20+
21+
var $new = $(ev.currentTarget).parent('div.one_kanban').find('.card');
22+
$new.find('.btn-addr').toggle();
23+
$new.removeClass(cardClass);
24+
$new.addClass('bg-primary border border-primary');
25+
26+
var $form = $(ev.currentTarget).parent('div.one_kanban').find('form.d-none');
27+
$.post($form.attr('action'), $form.serialize() + '&xhr=1').done(function () {
28+
self._updateCartTotals();
29+
});
30+
},
31+
32+
_updateCartTotals: function () {
33+
jsonrpc("/shop/cart/update_total", {})
34+
.then((data) => {
35+
if (!data.cart_quantity) {
36+
return window.location.reload();
37+
}
38+
$("#order_total_untaxed .oe_currency_value").html(data.amount_untaxed.toFixed(2));
39+
$("#order_total_taxes .oe_currency_value").html(data.amount_tax.toFixed(2));
40+
$("#order_total .oe_currency_value").html(data.amount_total.toFixed(2));
41+
$("#amount_total_summary .oe_currency_value").html(data.amount_total.toFixed(2));
42+
43+
let $cartRows = $("#cart_products tr");
44+
data.line_items.forEach((line, index) => {
45+
let $row = $cartRows.eq(index);
46+
$row.find(".oe_currency_value").text(line.subtotal.toFixed(2));
47+
});
48+
})
49+
.catch((err) => {
50+
console.error("Error updating cart totals:", err);
51+
});
52+
}
53+
});
+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<odoo>
2+
<record id="wizard_view_inherit" model="ir.ui.view">
3+
<field name="name">portal.wizard.view.inherit</field>
4+
<field name="model">portal.wizard</field>
5+
<field name="inherit_id" ref="portal.wizard_view"/>
6+
<field name="arch" type="xml">
7+
<xpath expr="//field[@name='is_internal']" position="after">
8+
<field name="can_edit" column_invisible="True"/>
9+
</xpath>
10+
<!-- Restrict Email Editing -->
11+
<xpath expr="//field[@name='user_ids']/tree/field[@name='email']" position="attributes">
12+
<attribute name="readonly">not can_edit</attribute>
13+
</xpath>
14+
15+
<!-- Hide "Grant Access," "Revoke Access," and "Re-Invite" Buttons for Unauthorized Users -->
16+
<xpath expr="//button[@name='action_grant_access']" position="attributes">
17+
<attribute name="groups">super_portal.group_edit_portal_access</attribute>
18+
</xpath>
19+
<xpath expr="//button[@name='action_revoke_access']" position="attributes">
20+
<attribute name="groups">super_portal.group_edit_portal_access</attribute>
21+
</xpath>
22+
<xpath expr="//button[@name='action_invite_again']" position="attributes">
23+
<attribute name="groups">super_portal.group_edit_portal_access</attribute>
24+
</xpath>
25+
</field>
26+
</record>
27+
</odoo>
+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<odoo>
2+
<record id="view_partner_form" model="ir.ui.view">
3+
<field name="name">res.partner.form.view</field>
4+
<field name="model">res.partner</field>
5+
<field name="inherit_id" ref="base.view_partner_form"/>
6+
<field name="arch" type="xml">
7+
<!-- Ensure Parent Company field is visible & editable -->
8+
<xpath expr="//field[@name='parent_id']" position="attributes">
9+
<attribute name="invisible">not is_company</attribute>
10+
<attribute name="readonly">not is_company</attribute>
11+
</xpath>
12+
13+
<!-- Ensure Address Type (type) is editable and Text(Address) invisible -->
14+
<xpath expr="//field[@name='type']" position="attributes">
15+
<attribute name="invisible">False</attribute>
16+
</xpath>
17+
<xpath expr="//b[contains(text(), 'Address')]" position="attributes">
18+
<attribute name="invisible">True</attribute>
19+
</xpath>
20+
</field>
21+
</record>
22+
</odoo>

‎super_portal/views/templates.xml

+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<odoo>
3+
<template id="portal_checkout_override" inherit_id="website_sale.checkout">
4+
<!-- Billing address -->
5+
<xpath expr="//h4[contains(text(), 'Billing')]" position="replace">
6+
<button class="accordion-button px-0 collapsed"
7+
style="background-color: white"
8+
data-bs-toggle="collapse"
9+
data-bs-target=".all_billing"
10+
aria-expanded="false"
11+
aria-controls="billingAddressDetails">
12+
<div class="d-flex flex-wrap">
13+
<b class="w-100">Billing Address</b>
14+
</div>
15+
</button>
16+
</xpath>
17+
18+
<xpath expr="//b[contains(text(), 'Billing')]" position="after">
19+
<div class="row mb-2">
20+
<div class="col-lg-12">
21+
<input type="text" class="form-control o_billing_address_search"
22+
placeholder="Search Billing Address..."
23+
onkeyup="filterAddresses(this, 'all_billing')"/>
24+
</div>
25+
</div>
26+
</xpath>
27+
<!-- Shipping address -->
28+
<xpath expr="//h4[contains(text(), 'Shipping')]" position="replace">
29+
<button class="accordion-button px-0 collapsed"
30+
style="background-color: white"
31+
data-bs-toggle="collapse"
32+
data-bs-target=".all_shipping"
33+
aria-expanded="false"
34+
aria-controls="shippingAddressDetails">
35+
<div class="d-flex flex-wrap">
36+
<b class="w-100">Shipping Address</b>
37+
</div>
38+
</button>
39+
</xpath>
40+
41+
<xpath expr="//b[contains(text(), 'Shipping')]" position="after">
42+
<div class="row mb-2">
43+
<div class="col-lg-12">
44+
<input type="text" class="form-control o_shipping_address_search"
45+
placeholder="Search Shipping Address..."
46+
onkeyup="filterAddresses(this, 'all_shipping')"/>
47+
</div>
48+
</div>
49+
</xpath>
50+
</template>
51+
52+
<!-- Collapsed if more then 6 address avilable -->
53+
<template id="portal_row_address_override" inherit_id="website_sale.row_addresses">
54+
<xpath expr="//div" position="attributes">
55+
<attribute name="t-attf-class">
56+
{{'all_billing' if is_invoice else 'all_shipping'}}
57+
row row-cols-md-2 row-cols-lg-3 g-3 flex-nowrap flex-md-wrap mb32
58+
{{'accordion-collapse collapse' if len(addresses) &gt; 6 else 'accordion-collapse collapse show'}}
59+
</attribute>
60+
</xpath>
61+
</template>
62+
</odoo>

0 commit comments

Comments
 (0)
Please sign in to comment.