Skip to content

Commit 256fd69

Browse files
committed
[ADD] better_ribbons: enhance product ribbons with badges and auto-assignment
- Two display styles: classic ribbon or compact badge - Automatic ribbon assignment based on conditions: * Out of stock products * Products on sale/discount * Newly published products (configurable timeframe) - Manual override capability - Ribbon sequence/priority ordering - Publish date tracking for "new" ribbon logic
1 parent 4c650f3 commit 256fd69

14 files changed

+469
-0
lines changed

better_ribbons/__init__.py

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

better_ribbons/__manifest__.py

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
'name': 'Better Ribbons',
3+
'version': '1.0',
4+
'description': 'Improved ribbons with badges and auto-assign',
5+
'author': 'Aryan Donga (ardo)',
6+
'license': 'LGPL-3',
7+
'depends': ['base', 'website_sale', 'website_sale_stock'],
8+
'installable': True,
9+
'auto_install': False,
10+
'data': [
11+
'views/product_ribbon_views.xml',
12+
'views/snippets.xml',
13+
'views/website_templates.xml',
14+
],
15+
'assets': {
16+
'website.assets_wysiwyg': [
17+
'better_ribbons/static/src/js/website_sale_editor.js'
18+
],
19+
'website.backend_assets_all_wysiwyg': [
20+
'better_ribbons/static/src/js/components/wysiwyg_adapter/wysiwyg_adapter.js'
21+
],
22+
},
23+
}
+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import main

better_ribbons/controllers/main.py

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from odoo.addons.website_sale.controllers.main import WebsiteSale
2+
from odoo.http import request, route
3+
4+
5+
class WebsiteSaleExtended(WebsiteSale):
6+
@route(
7+
[
8+
'/shop',
9+
'/shop/page/<int:page>',
10+
'/shop/category/<model("product.public.category"):category>',
11+
'/shop/category/<model("product.public.category"):category>/page/<int:page>',
12+
],
13+
type='http',
14+
auth='public',
15+
website=True,
16+
)
17+
def shop(
18+
self,
19+
page=0,
20+
category=None,
21+
search='',
22+
min_price=0.0,
23+
max_price=0.0,
24+
ppg=False,
25+
**post,
26+
):
27+
response = super().shop(
28+
page, category, search, min_price, max_price, ppg, **post
29+
)
30+
31+
if products := response.qcontext.get('products'):
32+
products_prices = response.qcontext.get('products_prices')
33+
for product in products:
34+
product._set_ribbon(products_prices.get(product.id))
35+
36+
return response
37+
38+
@route(
39+
['/shop/<model("product.template"):product>'],
40+
type='http',
41+
auth='public',
42+
website=True,
43+
readonly=True,
44+
)
45+
def product(self, product, category='', search='', **kwargs):
46+
response = super().product(product, category, search, **kwargs)
47+
48+
if prd := response.qcontext.get('product'):
49+
prices = prd._get_sales_prices(request.env['website'].get_current_website())
50+
prd._set_ribbon(prices.get(prd.id))
51+
52+
return response

better_ribbons/models/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from . import product_ribbon
2+
from . import product_template
+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from odoo import fields, models
2+
3+
4+
class ProductRibbon(models.Model):
5+
_inherit = 'product.ribbon'
6+
_order = 'sequence asc, create_date asc'
7+
8+
style = fields.Selection(
9+
[('ribbon', 'Ribbon'), ('tag', 'Badge')],
10+
string='Style',
11+
required=True,
12+
default='ribbon',
13+
)
14+
15+
assign = fields.Selection(
16+
[
17+
('manually', 'Manually'),
18+
('sale', 'Sale'),
19+
('out_of_stock', 'Out of Stock'),
20+
('new', 'New'),
21+
],
22+
string='Assign',
23+
required=True,
24+
default='manually',
25+
)
26+
27+
new_until = fields.Integer(default=30, help='Days to consider a product as new')
28+
sequence = fields.Integer(default=10, help='Sequence to prioritize ribbons')
29+
30+
def _get_position_class(self):
31+
return f'o_{self.style}_{self.position}'
+65
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from odoo import api, fields, models
2+
3+
4+
class ProductTemplate(models.Model):
5+
_inherit = 'product.template'
6+
7+
publish_date = fields.Date(readonly=True, copy=False)
8+
has_manual_ribbon = fields.Boolean(
9+
compute='_compute_has_manual_ribbon', copy=False, store=True
10+
)
11+
12+
@api.depends('website_ribbon_id')
13+
def _compute_has_manual_ribbon(self):
14+
for product in self:
15+
if not product.website_ribbon_id:
16+
product.has_manual_ribbon = False
17+
18+
def _set_ribbon(self, products_prices):
19+
self.ensure_one()
20+
product_ribbons = self.env['product.ribbon'].sudo()
21+
22+
if self.has_manual_ribbon:
23+
return None
24+
25+
def _add_ribbon(ribbon):
26+
if ribbon.id != self.website_ribbon_id.id:
27+
self.with_context(auto_assign_ribbon=True).write({
28+
'website_ribbon_id': ribbon.id
29+
})
30+
31+
stock_ribbon = product_ribbons.search([('assign', '=', 'out_of_stock')])
32+
if stock_ribbon and self._is_sold_out() and not self.allow_out_of_stock_order:
33+
return _add_ribbon(stock_ribbon[0])
34+
35+
sale_ribbon = product_ribbons.search([('assign', '=', 'sale')])
36+
if sale_ribbon and (
37+
products_prices.get('price_reduce')
38+
< products_prices.get('base_price', self.list_price)
39+
):
40+
return _add_ribbon(sale_ribbon[0])
41+
42+
new_ribbon = product_ribbons.search([('assign', '=', 'new')])
43+
if new_ribbon and self.publish_date:
44+
if (fields.Date.today() - self.publish_date).days <= new_ribbon.new_until:
45+
return _add_ribbon(new_ribbon[0])
46+
47+
return self.write({'website_ribbon_id': False})
48+
49+
def write(self, vals):
50+
if 'website_ribbon_id' in vals:
51+
if self.env.context.get('auto_assign_ribbon'):
52+
vals['has_manual_ribbon'] = False
53+
else:
54+
vals['has_manual_ribbon'] = bool(vals['website_ribbon_id'])
55+
56+
if 'is_published' in vals:
57+
# Set publish date when publishing
58+
if vals['is_published'] and not self.is_published:
59+
vals['publish_date'] = fields.Date.today()
60+
61+
# Clear publish date when unpublishing
62+
elif not vals['is_published']:
63+
vals['publish_date'] = False
64+
65+
return super().write(vals)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/** @odoo-module **/
2+
3+
import { patch } from '@web/core/utils/patch';
4+
import { WysiwygAdapterComponent } from '@website/components/wysiwyg_adapter/wysiwyg_adapter';
5+
6+
patch(WysiwygAdapterComponent.prototype, {
7+
/**
8+
* @override
9+
*/
10+
async init() {
11+
await super.init(...arguments);
12+
13+
let ribbons = [];
14+
if (this._isProductListPage()) {
15+
ribbons = await this.orm.searchRead(
16+
'product.ribbon',
17+
[],
18+
['id', 'name', 'bg_color', 'text_color', 'position', 'style']
19+
);
20+
}
21+
this.ribbons = Object.fromEntries(ribbons.map(ribbon => {
22+
return [ribbon.id, ribbon];
23+
}));
24+
this.originalRibbons = Object.assign({}, this.ribbons);
25+
this.productTemplatesRibbons = [];
26+
this.deletedRibbonClasses = '';
27+
this.ribbonPositionClasses = {'left': 'o_ribbon_left o_tag_left', 'right': 'o_ribbon_right o_tag_right'};
28+
}
29+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import options from '@web_editor/js/editor/snippets.options';
2+
3+
options.registry.WebsiteSaleProductsItem = options.registry.WebsiteSaleProductsItem.extend({
4+
willStart: async function () {
5+
const _super = this._super.bind(this);
6+
this.ppr = this.$target.closest('[data-ppr]').data('ppr');
7+
this.defaultSort = this.$target[0].closest('[data-default-sort]').dataset.defaultSort;
8+
this.productTemplateID = parseInt(this.$target.find('[data-oe-model="product.template"]').data('oe-id'));
9+
this.ribbonPositionClasses = {'left': 'o_ribbon_left o_tag_left', 'right': 'o_ribbon_right o_tag_right'};
10+
this.ribbons = await new Promise(resolve => this.trigger_up('get_ribbons', {callback: resolve}));
11+
this.$ribbon = this.$target.find('.o_ribbon');
12+
return _super(...arguments);
13+
},
14+
15+
async setRibbonPosition(previewMode, widgetValue, params) {
16+
const ribbonClasses = this.$ribbon[0].classList;
17+
const ribbonData = this.ribbons[this.$target[0].dataset.ribbonId] || {};
18+
const style = params?.style || ribbonData?.style || 'ribbon';
19+
ribbonClasses.remove('o_ribbon_right', 'o_ribbon_left', 'o_tag_right', 'o_tag_left');
20+
ribbonClasses.add(`o_${style}_${widgetValue}`);
21+
22+
await this._saveRibbon();
23+
},
24+
25+
async _computeWidgetState(methodName, params) {
26+
const classList = this.$ribbon[0].classList;
27+
switch (methodName) {
28+
case 'setRibbon':
29+
return this.$target.attr('data-ribbon-id') || '';
30+
case 'setRibbonName':
31+
return this.$ribbon.text();
32+
case 'setRibbonPosition': {
33+
return (
34+
classList.contains('o_ribbon_left')
35+
|| classList.contains('o_tag_left')
36+
) ? 'left' : 'right';
37+
}
38+
case 'setRibbonStyle': {
39+
return this.$ribbon.attr('data-style') ||
40+
((
41+
classList.contains('o_tag_left')
42+
|| classList.contains('o_tag_right')
43+
) ? 'tag' : 'ribbon');
44+
}
45+
}
46+
return this._super(methodName, params);
47+
},
48+
49+
async _saveRibbon(isNewRibbon = false) {
50+
const ribbonElement = this.$ribbon[0];
51+
const ribbon = {
52+
'name': this.$ribbon.text().trim(),
53+
'bg_color': ribbonElement.style.backgroundColor,
54+
'text_color': ribbonElement.style.color,
55+
'position': (ribbonElement.classList.contains('o_ribbon_left') || ribbonElement.classList.contains('o_tag_left')) ? 'left' : 'right',
56+
'style': ribbonElement.dataset?.style || 'ribbon'
57+
};
58+
ribbon.id = isNewRibbon ? Date.now() : parseInt(this.$target.closest('.oe_product')[0].dataset.ribbonId);
59+
this.trigger_up('set_ribbon', {ribbon});
60+
this.ribbons = await new Promise(resolve => this.trigger_up('get_ribbons', {callback: resolve}));
61+
this.rerender = true;
62+
await this._setRibbon(ribbon.id);
63+
},
64+
65+
async _setRibbon(ribbonId) {
66+
this.$target[0].dataset.ribbonId = ribbonId;
67+
this.trigger_up('set_product_ribbon', {
68+
templateId: this.productTemplateID,
69+
ribbonId: ribbonId || false
70+
});
71+
const ribbon = (
72+
this.ribbons[ribbonId] ||
73+
{name: '', bg_color: '', text_color: '', position: 'left', style: 'ribbon'}
74+
);
75+
76+
const $editableDocument = $(this.$target[0].ownerDocument.body);
77+
const $ribbons = $editableDocument.find(`[data-ribbon-id="${ribbonId}"] .o_ribbon`);
78+
$ribbons.empty().append(ribbon.name);
79+
$ribbons.removeClass('o_ribbon_left o_tag_left o_ribbon_right o_tag_right');
80+
81+
$ribbons.addClass(`o_${ribbon.style}_${ribbon.position}`);
82+
$ribbons.attr('data-style', ribbon.style);
83+
$ribbons.css('background-color', ribbon.bg_color || '');
84+
$ribbons.css('color', ribbon.text_color || '');
85+
86+
if (!this.ribbons[ribbonId]) {
87+
$editableDocument.find(`[data-ribbon-id="${ribbonId}"]`).each((index, product) => delete product.dataset.ribbonId);
88+
}
89+
90+
this.$ribbon.addClass('o_dirty');
91+
},
92+
93+
async setRibbonStyle(previewMode, widgetValue, params) {
94+
this.$ribbon.attr('data-style', widgetValue);
95+
96+
await this._saveRibbon();
97+
}
98+
});

better_ribbons/tests/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import test_better_ribbons

0 commit comments

Comments
 (0)