[MERGE] base, various: avoid storing country flags and strengthen update of countries

Currently, 200+ country flags are stored in ALL db's as ir_attachment's (thus in the filestore), and also duplicated at each backup, ...

To reduce the footprint of Odoo databases (and their backups), the country flags are now considered as static images and accessed as such, without storing their references in database.

##### New widget for static image URL's

A new `image_url` widget is added (frontend & backend), to allow specification of a static URL in Char fields, enabling the access and display of `res.country` flags through their static urls : `/base/static/img/country_flags/<country_code>.png`.

##### Restriction of countries creation & deletion

To ensure no inconsistency can be introduced because the flags are now static, we now restrict the creation and deletion of countries.

Countries are nearly static data, they shouldn't ever be deleted... This prevents common database inconsistencies, bugs and feature disabling.

Manually created countries can easily be wrongly configured, won't behave as expected and/or will crash with Odoo integration (delivery carriers, taxes, VAT validation/formatting).

Therefore, creation/deletion of countries is limited to admins, supposed to know what they are doing...

Will probably be backported in V14

LINKS

Task ID-2325371
COM PR odoo/odoo#52774
UPG PR odoo/upgrade#1338

Signed-off-by: Victor Feyens (vfe) <vfe@odoo.com>
This commit is contained in:
Odoo's Mergebot
2020-09-03 01:10:04 +02:00
committed by GitHub
20 changed files with 285 additions and 276 deletions
+37 -19
View File
@@ -21,20 +21,12 @@ class Image(models.AbstractModel):
_description = 'Qweb Field Image'
_inherit = 'ir.qweb.field.image'
@api.model
def record_to_html(self, record, field_name, options):
assert options['tagName'] != 'img',\
"Oddly enough, the root tag of an image field can not be img. " \
"That is because the image goes into the tag, or it gets the " \
"hose again."
if options.get('qweb_img_raw_data', False):
return super(Image, self).record_to_html(record, field_name, options)
aclasses = ['img', 'img-fluid'] if options.get('qweb_img_responsive', True) else ['img']
aclasses += options.get('class', '').split()
classes = ' '.join(map(escape, aclasses))
def _get_src_urls(self, record, field_name, options):
"""Considering the rendering options, returns the src and data-zoom-image urls.
:return: src, src_zoom urls
:rtype: tuple
"""
max_size = None
if options.get('resize'):
max_size = options.get('resize')
@@ -56,6 +48,30 @@ class Image(models.AbstractModel):
src = '/web/image/%s/%s/%s%s/%s?unique=%s' % (record._name, record.id, options.get('preview_image', field_name), max_size, url_quote(filename), sha)
src_zoom = None
if options.get('zoom') and getattr(record, options['zoom'], None):
src_zoom = '/web/image/%s/%s/%s%s/%s?unique=%s' % (record._name, record.id, options['zoom'], max_size, url_quote(filename), sha)
elif options.get('zoom'):
src_zoom = options['zoom']
return src, src_zoom
@api.model
def record_to_html(self, record, field_name, options):
assert options['tagName'] != 'img',\
"Oddly enough, the root tag of an image field can not be img. " \
"That is because the image goes into the tag, or it gets the " \
"hose again."
if options.get('qweb_img_raw_data', False):
return super(Image, self).record_to_html(record, field_name, options)
aclasses = ['img', 'img-fluid'] if options.get('qweb_img_responsive', True) else ['img']
aclasses += options.get('class', '').split()
classes = ' '.join(map(escape, aclasses))
src, src_zoom = self._get_src_urls(record, field_name, options)
if options.get('alt-field') and getattr(record, options['alt-field'], None):
alt = escape(record[options['alt-field']])
elif options.get('alt'):
@@ -63,12 +79,6 @@ class Image(models.AbstractModel):
else:
alt = escape(record.display_name)
src_zoom = None
if options.get('zoom') and getattr(record, options['zoom'], None):
src_zoom = '/web/image/%s/%s/%s%s/%s?unique=%s' % (record._name, record.id, options['zoom'], max_size, url_quote(filename), sha)
elif options.get('zoom'):
src_zoom = options['zoom']
itemprop = None
if options.get('itemprop'):
itemprop = options['itemprop']
@@ -96,3 +106,11 @@ class Image(models.AbstractModel):
img.append('/>')
return u''.join(img)
class ImageUrlConverter(models.AbstractModel):
_description = 'Qweb Field Image'
_inherit = 'ir.qweb.field.image_url'
def _get_src_urls(self, record, field_name, options):
image_url = record[options.get('preview_image', field_name)]
return image_url, options.get("zoom", None)
@@ -1955,6 +1955,45 @@ var FieldBinaryImage = AbstractFieldBinary.extend({
},
});
var CharImageUrl = AbstractField.extend({
className: 'o_field_image',
description: _lt("Image"),
supportedFieldTypes: ['char'],
placeholder: "/web/static/src/img/placeholder.png",
_renderReadonly: function () {
var self = this;
const url = this.value;
if (url) {
var $img = $(qweb.render("FieldBinaryImage-img", {widget: this, url: url}));
// override css size attributes (could have been defined in css files)
// if specified on the widget
const width = this.nodeOptions.size ? this.nodeOptions.size[0] : this.attrs.width;
const height = this.nodeOptions.size ? this.nodeOptions.size[1] : this.attrs.height;
if (width) {
$img.attr('width', width);
$img.css('max-width', width + 'px');
}
if (height) {
$img.attr('height', height);
$img.css('max-height', height + 'px');
}
this.$('> img').remove();
this.$el.prepend($img);
$img.one('error', function () {
$img.attr('src', self.placeholder);
self.displayNotification({
type: 'info',
message: _t("Could not display the specified image url."),
});
});
}
return this._super.apply(this, arguments);
},
});
var KanbanFieldBinaryImage = FieldBinaryImage.extend({
// In kanban views, there is a weird logic to determine whether or not a
// click on a card should open the record in a form view. This logic checks
@@ -1966,6 +2005,17 @@ var KanbanFieldBinaryImage = FieldBinaryImage.extend({
events: {},
});
var KanbanCharImageUrl = CharImageUrl.extend({
// In kanban views, there is a weird logic to determine whether or not a
// click on a card should open the record in a form view. This logic checks
// if the clicked element has click handlers bound on it, and if so, does
// not open the record (assuming that the click will be handled by someone
// else). In the case of this widget, there are clicks handler but they
// only apply in edit mode, which is never the case in kanban views, so we
// simply remove them.
events: {},
});
var FieldBinaryFile = AbstractFieldBinary.extend({
description: _lt("File"),
template: 'FieldBinaryFile',
@@ -3539,6 +3589,8 @@ return {
AbstractFieldBinary: AbstractFieldBinary,
FieldBinaryImage: FieldBinaryImage,
KanbanFieldBinaryImage: KanbanFieldBinaryImage,
CharImageUrl: CharImageUrl,
KanbanCharImageUrl: KanbanCharImageUrl,
FieldBoolean: FieldBoolean,
BooleanToggle: BooleanToggle,
FieldChar: FieldChar,
@@ -43,7 +43,9 @@ registry
.add('CopyClipboardText', basic_fields.TextCopyClipboard)
.add('CopyClipboardChar', basic_fields.CharCopyClipboard)
.add('image', basic_fields.FieldBinaryImage)
.add('image_url', basic_fields.CharImageUrl)
.add('kanban.image', basic_fields.KanbanFieldBinaryImage)
.add('kanban.image_url', basic_fields.KanbanCharImageUrl)
.add('binary', basic_fields.FieldBinaryFile)
.add('pdf_viewer', basic_fields.FieldPdfViewer)
.add('monetary', basic_fields.FieldMonetary)
@@ -24,6 +24,9 @@ var _t = core._t;
// Base64 images for testing purpose
const MY_IMAGE = 'iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==';
const PRODUCT_IMAGE = 'R0lGODlhDAAMAKIFAF5LAP/zxAAAANyuAP/gaP///wAAAAAAACH5BAEAAAUALAAAAAAMAAwAAAMlWLPcGjDKFYi9lxKBOaGcF35DhWHamZUW0K4mAbiwWtuf0uxFAgA7';
const FR_FLAG_URL = '/base/static/img/country_flags/fr.png';
const EN_FLAG_URL = '/base/static/img/country_flags/gb.png';
QUnit.module('fields', {}, function () {
@@ -2889,6 +2892,131 @@ QUnit.module('basic_fields', {
form.destroy();
});
/**
* Same tests than for Image fields, but for Char fields with image_url widget.
*/
QUnit.module('FieldChar-ImageUrlWidget', {
beforeEach: function () {
// specific sixth partner data for image_url widget tests
this.data.partner.records.push({id: 6, bar: false, foo: FR_FLAG_URL, int_field: 5, qux: 0.0, timmy: []});
},
});
QUnit.test('image fields are correctly rendered', async function (assert) {
assert.expect(6);
const form = await createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<field name="foo" widget="image_url" options="{\'size\': [90, 90]}"/> ' +
'</form>',
res_id: 6,
async mockRPC(route, args) {
if (route === FR_FLAG_URL) {
assert.ok(true, "the correct route should have been called.");
}
return this._super.apply(this, arguments);
},
});
assert.hasClass(form.$('div[name="foo"]'), 'o_field_image',
"the widget should have the correct class");
assert.containsOnce(form, 'div[name="foo"] > img',
"the widget should contain an image");
assert.hasClass(form.$('div[name="foo"] > img'), 'img-fluid',
"the image should have the correct class");
assert.hasAttrValue(form.$('div[name="foo"] > img'), 'width', "90",
"the image should correctly set its attributes");
assert.strictEqual(form.$('div[name="foo"] > img').css('max-width'), "90px",
"the image should correctly set its attributes");
form.destroy();
});
QUnit.test('image_url widget in subviews are loaded correctly', async function (assert) {
assert.expect(6);
this.data.partner_type.fields.image = {name: 'image', type: 'char'};
this.data.partner_type.records[0].image = EN_FLAG_URL;
this.data.partner.records[5].timmy = [12];
const form = await createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<field name="foo" widget="image_url" options="{\'size\': [90, 90]}"/>' +
'<field name="timmy" widget="many2many">' +
'<tree>' +
'<field name="display_name"/>' +
'</tree>' +
'<form>' +
'<field name="image" widget="image_url"/>' +
'</form>' +
'</field>' +
'</form>',
res_id: 6,
async mockRPC(route) {
if (route === FR_FLAG_URL) {
assert.step("The view's image should have been fetched");
return 'wow';
}
if (route === EN_FLAG_URL) {
assert.step("The dialog's image should have been fetched");
return;
}
return this._super.apply(this, arguments);
},
});
assert.verifySteps(["The view's image should have been fetched"]);
assert.containsOnce(form, 'tr.o_data_row',
'There should be one record in the many2many');
// Actual flow: click on an element of the m2m to get its form view
await testUtils.dom.click(form.$('tbody td:contains(gold)'));
assert.strictEqual($('.modal').length, 1,
'The modal should have opened');
assert.verifySteps(["The dialog's image should have been fetched"]);
form.destroy();
});
QUnit.test('image fields in x2many list are loaded correctly', async function (assert) {
assert.expect(2);
this.data.partner_type.fields.image = {name: 'image', type: 'char'};
this.data.partner_type.records[0].image = EN_FLAG_URL;
this.data.partner.records[5].timmy = [12];
const form = await createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<field name="timmy" widget="many2many">' +
'<tree>' +
'<field name="image" widget="image_url"/>' +
'</tree>' +
'</field>' +
'</form>',
res_id: 6,
async mockRPC(route) {
if (route === EN_FLAG_URL) {
assert.ok(true, "The list's image should have been fetched");
return;
}
return this._super.apply(this, arguments);
},
});
assert.containsOnce(form, 'tr.o_data_row',
'There should be one record in the many2many');
form.destroy();
});
QUnit.module('JournalDashboardGraph', {
beforeEach: function () {
_.extend(this.data.partner.fields, {
+2 -2
View File
@@ -250,8 +250,8 @@
<field name="public" eval="True"/>
<field name="name">business_conference.jpg</field>
<field name="res_model">ir.ui.view</field>
<field name="type">binary</field>
<field name="datas" type="base64" file="website/static/src/img/library/business_conference.jpg"/>
<field name="type">url</field>
<field name="url">/website/static/src/img/library/business_conference.jpg</field>
</record>
<record id="website.library_image_01" model="ir.attachment">
<field name="public" eval="True"/>
+1 -1
View File
@@ -38,7 +38,7 @@ class WebsiteVisitor(models.Model):
# localisation and info
country_id = fields.Many2one('res.country', 'Country', readonly=True)
country_flag = fields.Binary(related="country_id.image", string="Country Flag")
country_flag = fields.Char(related="country_id.image_url", string="Country Flag")
lang_id = fields.Many2one('res.lang', string='Language', help="Language from the website when visitor has been created")
timezone = fields.Selection(_tz_get, string='Timezone')
email = fields.Char(string='Email', compute='_compute_email_phone')
-2
View File
@@ -33,8 +33,6 @@ class UtilPerf(HttpCase):
class TestStandardPerformance(UtilPerf):
def test_10_perf_sql_img_controller(self):
self.authenticate('demo', 'demo')
url = '/web/image/res.country/4/image'
self.assertEqual(self._get_url_hot_query(url), 4)
url = '/web/image/res.users/2/image_256'
self.assertEqual(self._get_url_hot_query(url), 8)
+11 -7
View File
@@ -82,6 +82,7 @@
<kanban class="o_wvisitor_kanban" sample="1">
<field name="id"/>
<field name="country_id"/>
<field name="country_flag"/>
<field name="email"/>
<field name="is_connected"/>
<field name="display_name"/>
@@ -109,10 +110,10 @@
<span class="fa fa-circle text-success" t-if="record.is_connected.raw_value" aria-label="Online" title="Online"/>
<span class="fa fa-circle text-danger" t-else="" aria-label="Offline" title="Offline"/>
</div>
<div>
<img t-if="record.country_id.raw_value"
t-att-src="kanban_image('res.country', 'image', record.country_id.raw_value)"
class="o_country_flag" alt="Country"/>
<!-- Double check is necessary for sample view (or error image are shown) -->
<div t-if="record.country_id.raw_value and record.country_flag.raw_value">
<img t-att-src="record.country_flag.raw_value"
class="o_country_flag" alt="Country"/>
</div>
</div>
</div>
@@ -147,7 +148,8 @@
</div>
<strong>
<img t-if="record.country_id.raw_value"
t-att-src="kanban_image('res.country', 'image', record.country_id.raw_value)" class="o_country_flag" alt="Country"/>
t-att-src="record.country_flag.raw_value"
class="o_country_flag" alt="Country"/>
<field name="display_name"/>
</strong>
<div class="mb-2">Active <field name="time_since_last_action"/></div>
@@ -196,7 +198,9 @@
<field name="visitor_page_count" widget="statinfo" string="Page views"/>
</button>
</div>
<div class="float-right" attrs="{'invisible': [('country_id', '=', False)]}"><field name="country_flag" widget="image" options='{"size": [32, 32]}'/></div>
<div class="float-right" attrs="{'invisible': [('country_id', '=', False)]}">
<field name="country_flag" widget="image_url" options='{"size": [32, 32]}'/>
</div>
<div class="oe_title">
<h1><field name="display_name"/></h1>
</div>
@@ -227,7 +231,7 @@
<field name="arch" type="xml">
<tree string="Web Visitors" decoration-success="is_connected" decoration-danger="not is_connected" sample="1">
<!--TODO DBE : Handle no_label in treeview-->
<field name="country_flag" widget="image" options='{"size": [20, 20]}' string=" "/>
<field name="country_flag" widget="image_url" options='{"size": [20, 20]}' string=" "/>
<field name="display_name"/>
<field name="create_date"/>
<field name="last_connection_datetime"/>
@@ -92,11 +92,11 @@ class EventSponsor(models.Model):
sponsor.is_in_opening_hours = opening_from <= now_tz < opening_to
@api.depends('partner_id.country_id.image')
@api.depends('partner_id.country_id.image_url')
def _compute_country_flag_url(self):
for sponsor in self:
if sponsor.partner_id.country_id:
sponsor.country_flag_url = self.env['website'].image_url(sponsor.partner_id.country_id, 'image', size=256)
sponsor.country_flag_url = sponsor.partner_id.country_id.image_url
else:
sponsor.country_flag_url = False
+1 -1
View File
@@ -1641,7 +1641,7 @@
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input" t-attf-id="country_#{country.id}" t-att-value="country.id"/>
<label class="custom-control-label" t-attf-for="country_#{country.id}">
<span t-field="country.image" t-options='{"widget": "image", "class": "country_flag"}' class="mr-2"/>
<span t-field="country.image_url" t-options='{"widget": "image_url", "class": "country_flag"}' class="mr-2"/>
<span t-esc="country.name"/>
</label>
</div>
File diff suppressed because it is too large Load Diff
+11
View File
@@ -385,6 +385,17 @@ class ImageConverter(models.AbstractModel):
return u'<img src="data:%s;base64,%s">' % (Image.MIME[image.format], value.decode('ascii'))
class ImageUrlConverter(models.AbstractModel):
""" ``image_url`` widget rendering, inserts an image tag in the
document.
"""
_name = 'ir.qweb.field.image_url'
_description = 'Qweb Field Image'
_inherit = 'ir.qweb.field.image'
@api.model
def value_to_html(self, value, options):
return u'<img src="%s">' % (value)
class MonetaryConverter(models.AbstractModel):
""" ``monetary`` converter, has a mandatory option
+31 -1
View File
@@ -10,6 +10,24 @@ from odoo.tools.translate import _
_logger = logging.getLogger(__name__)
FLAG_MAPPING = {
"GF": "fr",
"BV": "no",
"BQ": "nl",
"GP": "fr",
"HM": "au",
"YT": "fr",
"RE": "fr",
"MF": "fr",
"UM": "us",
}
NO_FLAG_COUNTRIES = [
"AQ", #Antarctica
"SJ", #Svalbard + Jan Mayen : separate jurisdictions : no dedicated flag
]
class Country(models.Model):
_name = 'res.country'
_description = 'Country'
@@ -37,7 +55,10 @@ class Country(models.Model):
"(in reports for example), while this field is used to modify the input form for "
"addresses.")
currency_id = fields.Many2one('res.currency', string='Currency')
image = fields.Binary(attachment=True)
image_url = fields.Char(
compute="_compute_image_url", string="Flag",
help="Url of static flag image",
)
phone_code = fields.Integer(string='Country Calling Code')
country_group_ids = fields.Many2many('res.country.group', 'res_country_res_country_group_rel',
'res_country_id', 'res_country_group_id', string='Country Groups')
@@ -90,6 +111,15 @@ class Country(models.Model):
self.ensure_one()
return re.findall(r'\((.+?)\)', self.address_format)
@api.depends('code')
def _compute_image_url(self):
for country in self:
if not country.code or country.code in NO_FLAG_COUNTRIES:
country.image_url = False
else:
code = FLAG_MAPPING.get(country.code, country.code.lower())
country.image_url = "/base/static/img/country_flags/%s.png" % code
class CountryGroup(models.Model):
_description = "Country Group"
@@ -43,7 +43,8 @@
"access_res_country_group_all","res_country group_user_all","model_res_country",,1,0,0,0
"access_res_country_state_group_all","res_country_state group_user_all","model_res_country_state",,1,0,0,0
"access_res_country_group_group_all","res_country_group group_user_all","model_res_country_group",,1,0,0,0
"access_res_country_group_user","res_country group_user","model_res_country","group_partner_manager",1,1,1,1
"access_res_country_group_user","res_country group_user","model_res_country","group_partner_manager",1,0,0,0
"access_res_country_group_system","res_country_group_system","model_res_country","group_system",1,1,1,1
"access_res_country_state_group_user","res_country_state group_user","model_res_country_state","group_partner_manager",1,1,1,1
"access_res_country_group_group_user","res_country_group group_user","model_res_country_group","group_partner_manager",1,1,1,1
"access_res_currency_group_all","res_currency group_all","model_res_currency",,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
43 access_res_country_group_all res_country group_user_all model_res_country 1 0 0 0
44 access_res_country_state_group_all res_country_state group_user_all model_res_country_state 1 0 0 0
45 access_res_country_group_group_all res_country_group group_user_all model_res_country_group 1 0 0 0
46 access_res_country_group_user res_country group_user model_res_country group_partner_manager 1 1 0 1 0 1 0
47 access_res_country_group_system res_country_group_system model_res_country group_system 1 1 1 1
48 access_res_country_state_group_user res_country_state group_user model_res_country_state group_partner_manager 1 1 1 1
49 access_res_country_group_group_user res_country_group group_user model_res_country_group group_partner_manager 1 1 1 1
50 access_res_currency_group_all res_currency group_all model_res_currency 1 0 0 0
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

@@ -7,8 +7,8 @@
<t t-if="not object.name and object.parent_name">
<span itemprop="name" t-esc="object.parent_name"/>
</t>
<t t-if="options.get('country_image') and 'country_id' in fields and object.country_id and object.country_id.image">
<span t-field="object.country_id.image" t-options='{"widget": "image", "class": "country_flag"}'/>
<t t-if="options.get('country_image') and 'country_id' in fields and object.country_id and object.country_id.image_url">
<span t-field="object.country_id.image_url" t-options='{"widget": "image_url", "class": "country_flag"}'/>
</t>
</div>
</template>
+3 -3
View File
@@ -8,7 +8,7 @@
<field name="name">res.country.tree</field>
<field name="model">res.country</field>
<field name="arch" type="xml">
<tree string="Country">
<tree string="Country" create="0" delete="0">
<field name="name"/>
<field name="code"/>
</tree>
@@ -19,11 +19,11 @@
<field name="name">res.country.form</field>
<field name="model">res.country</field>
<field name="arch" type="xml">
<form>
<form create="0" delete="0">
<sheet>
<div class="oe_button_box">
</div>
<field name="image" widget="image" class="oe_avatar"/>
<field name="image_url" widget="image_url" class="oe_avatar"/>
<group name="main_group">
<group name="country_details">
<field name="name"/>