Skip to content

Commit c285582

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 c285582

14 files changed

+470
-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 response.qcontext.get('products'):
32+
products_prices = response.qcontext.get('products_prices')
33+
for product in response.qcontext['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+
current_price = products_prices.get('price_reduce')
36+
sale_ribbon = product_ribbons.search([('assign', '=', 'sale')])
37+
if sale_ribbon and (
38+
current_price < self.list_price or self.list_price < self.compare_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,99 @@
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 ribbon = this.$ribbon[0];
17+
const ribbonData = this.ribbons[this.$target[0].dataset.ribbonId] || {};
18+
const style = params?.style || ribbonData?.style || 'ribbon'; // Safe access // Remove both tag and ribbon position classes
19+
ribbon.classList.remove('o_ribbon_right', 'o_ribbon_left', 'o_tag_right', 'o_tag_left');
20+
ribbon.classList.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 text = this.$ribbon.text().trim();
51+
52+
const ribbon = {
53+
'name': text,
54+
'bg_color': this.$ribbon[0].style.backgroundColor,
55+
'text_color': this.$ribbon[0].style.color,
56+
'position': (this.$ribbon.attr('class').includes('o_ribbon_left') || this.$ribbon.attr('class').includes('o_tag_left')) ? 'left' : 'right',
57+
'style': this.$ribbon.attr('data-style') || 'ribbon'
58+
};
59+
ribbon.id = isNewRibbon ? Date.now() : parseInt(this.$target.closest('.oe_product')[0].dataset.ribbonId);
60+
this.trigger_up('set_ribbon', {ribbon: ribbon});
61+
this.ribbons = await new Promise(resolve => this.trigger_up('get_ribbons', {callback: resolve}));
62+
this.rerender = true;
63+
await this._setRibbon(ribbon.id);
64+
},
65+
66+
async _setRibbon(ribbonId) {
67+
this.$target[0].dataset.ribbonId = ribbonId;
68+
this.trigger_up('set_product_ribbon', {
69+
templateId: this.productTemplateID,
70+
ribbonId: ribbonId || false
71+
});
72+
const ribbon = (
73+
this.ribbons[ribbonId] ||
74+
{name: '', bg_color: '', text_color: '', position: 'left', style: 'ribbon'}
75+
);
76+
77+
const $editableDocument = $(this.$target[0].ownerDocument.body);
78+
const $ribbons = $editableDocument.find(`[data-ribbon-id="${ribbonId}"] .o_ribbon`);
79+
$ribbons.empty().append(ribbon.name);
80+
$ribbons.removeClass('o_ribbon_left o_tag_left o_ribbon_right o_tag_right');
81+
82+
$ribbons.addClass(`o_${ribbon.style}_${ribbon.position}`);
83+
$ribbons.attr('data-style', ribbon.style);
84+
$ribbons.css('background-color', ribbon.bg_color || '');
85+
$ribbons.css('color', ribbon.text_color || '');
86+
87+
if (!this.ribbons[ribbonId]) {
88+
$editableDocument.find(`[data-ribbon-id="${ribbonId}"]`).each((index, product) => delete product.dataset.ribbonId);
89+
}
90+
91+
this.$ribbon.addClass('o_dirty');
92+
},
93+
94+
async setRibbonStyle(previewMode, widgetValue, params) {
95+
this.$ribbon.attr('data-style', widgetValue);
96+
97+
await this._saveRibbon();
98+
}
99+
});

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)