diff --git a/addons/base_vat_autocomplete/models/res_partner.py b/addons/base_vat_autocomplete/models/res_partner.py index 40bc80c1..a535514b 100644 --- a/addons/base_vat_autocomplete/models/res_partner.py +++ b/addons/base_vat_autocomplete/models/res_partner.py @@ -12,6 +12,9 @@ _logger = logging.getLogger(__name__) try: import stdnum.eu.vat as stdnum_vat + if not hasattr(stdnum_vat, "country_codes"): + # stdnum version >= 1.9 + stdnum_vat.country_codes = stdnum_vat._country_codes except ImportError: _logger.warning('Python `stdnum` library not found, unable to call VIES service to detect address based on VAT number.') stdnum_vat = None @@ -29,6 +32,11 @@ class ResPartner(models.Model): cp = lines.pop() city = lines.pop() return (cp, city) + elif country == 'SE': + result = re.match('([0-9]{3}\s?[0-9]{2})\s?([A-Z]+)', lines[-1]) + if result: + lines.pop() + return (result.group(1), result.group(2)) else: result = re.match('((?:L-|AT-)?[0-9\-]+[A-Z]{,2}) (.+)', lines[-1]) if result: diff --git a/addons/base_vat_autocomplete/views/res_company_view.xml b/addons/base_vat_autocomplete/views/res_company_view.xml index f4b66169..2e5ab411 100644 --- a/addons/base_vat_autocomplete/views/res_company_view.xml +++ b/addons/base_vat_autocomplete/views/res_company_view.xml @@ -11,7 +11,7 @@ - + diff --git a/addons/board/static/description/index.html b/addons/board/static/description/index.html index 0284e5b4..5647a4ef 100644 --- a/addons/board/static/description/index.html +++ b/addons/board/static/description/index.html @@ -18,7 +18,7 @@

Gather all your Flectra reports in one app

-After creating reports in each Odoo app, you can choose to make them as Favorite and adding those on your 'Dashboard'. Odoo Dashboard is the reporting module that gathers all your favorite graphs and tabs in one place. You company has no secret for you anymore, all your important figures are in front of you. +After creating reports in each Flectra app, you can choose to make them as Favorite and adding those on your 'Dashboard'. Flectra Dashboard is the reporting module that gathers all your favorite graphs and tabs in one place. You company has no secret for you anymore, all your important figures are in front of you.

@@ -40,7 +40,7 @@ After creating reports in each Odoo app, you can choose to make them as Favorite

-In each Odoo App, you can create detailed reports and graphs in any format you like without the need of an external program. Get statistics on any numbers in your company, from fuel costs in projects all the way to revenues in sales channels, and with inventory, and everything in between. +In each Flectra App, you can create detailed reports and graphs in any format you like without the need of an external program. Get statistics on any numbers in your company, from fuel costs in projects all the way to revenues in sales channels, and with inventory, and everything in between.

@@ -52,7 +52,7 @@ In each Odoo App, you can create detailed reports and graphs in any format you l

Filter all results to fit your field of research

-In each Odoo app, you'll need to create a report. In the reports you can filter and group each analysis using built-in filters, and create custom filters to gather only the information you are looking for. Save the filters you created in your favorites to access them anytime in just a click either from the concerned app, or in comparison with you other reports in the Odoo Dashboard app. +In each Flectra app, you'll need to create a report. In the reports you can filter and group each analysis using built-in filters, and create custom filters to gather only the information you are looking for. Save the filters you created in your favorites to access them anytime in just a click either from the concerned app, or in comparison with you other reports in the Flectra Dashboard app.

diff --git a/addons/board/static/src/js/dashboard.js b/addons/board/static/src/js/dashboard.js index 56174375..855dc03d 100644 --- a/addons/board/static/src/js/dashboard.js +++ b/addons/board/static/src/js/dashboard.js @@ -20,7 +20,7 @@ FormView.include({ */ init: function (viewInfo) { this._super.apply(this, arguments); - this.controllerParams.viewID = viewInfo.view_id; + this.controllerParams.customViewID = viewInfo.custom_view_id; }, }); @@ -33,7 +33,7 @@ FormController.include({ }), init: function (parent, model, renderer, params) { this._super.apply(this, arguments); - this.viewID = params.viewID; + this.customViewID = params.customViewID; }, //-------------------------------------------------------------------------- @@ -63,9 +63,9 @@ FormController.include({ var board = this.renderer.getBoard(); var arch = QWeb.render('DashBoard.xml', _.extend({}, board)); return this._rpc({ - route: '/web/view/add_custom', + route: '/web/view/edit_custom', params: { - view_id: this.viewID, + custom_id: this.customViewID, arch: arch, } }).then(dataManager.invalidate.bind(dataManager)); @@ -139,6 +139,31 @@ FormRenderer.include({ this._super.apply(this, arguments); this.noContentHelp = params.noContentHelp; this.actionsDescr = {}; + this._boardSubcontrollers = []; // for board: controllers of subviews + }, + /** + * Call `on_attach_callback` for each subview + * + * @override + */ + on_attach_callback: function () { + _.each(this._boardSubcontrollers, function (controller) { + if ('on_attach_callback' in controller) { + controller.on_attach_callback(); + } + }); + }, + /** + * Call `on_detach_callback` for each subview + * + * @override + */ + on_detach_callback: function () { + _.each(this._boardSubcontrollers, function (controller) { + if ('on_detach_callback' in controller) { + controller.on_detach_callback(); + } + }); }, //-------------------------------------------------------------------------- @@ -241,6 +266,7 @@ FormRenderer.include({ hasSelectors: false, }); return view.getController(self).then(function (controller) { + self._boardSubcontrollers.push(controller); return controller.appendTo(params.$node); }); }); diff --git a/addons/board/static/src/js/favorite_menu.js b/addons/board/static/src/js/favorite_menu.js index 6e88c6fc..b7a035e6 100644 --- a/addons/board/static/src/js/favorite_menu.js +++ b/addons/board/static/src/js/favorite_menu.js @@ -94,7 +94,10 @@ FavoriteMenu.include({ }) .then(function (r) { if (r) { - self.do_notify(_.str.sprintf(_t("'%s' added to dashboard"), name), ''); + self.do_notify( + _.str.sprintf(_t("'%s' added to dashboard"), name), + _t('Please refresh your browser for the changes to take effect.') + ); } else { self.do_warn(_t("Could not add filter to dashboard")); } diff --git a/addons/board/static/tests/dashboard_tests.js b/addons/board/static/tests/dashboard_tests.js index b6c994fd..5b9e05cc 100644 --- a/addons/board/static/tests/dashboard_tests.js +++ b/addons/board/static/tests/dashboard_tests.js @@ -3,6 +3,7 @@ flectra.define('board.dashboard_tests', function (require) { var testUtils = require('web.test_utils'); var FormView = require('web.FormView'); +var ListRenderer = require('web.ListRenderer'); var createView = testUtils.createView; @@ -123,8 +124,8 @@ QUnit.test('basic functionality, with one sub action', function (assert) { if (route === '/web/dataset/search_read') { assert.deepEqual(args.domain, [['foo', '!=', 'False']], "the domain should be passed"); } - if (route === '/web/view/add_custom') { - assert.step('add custom'); + if (route === '/web/view/edit_custom') { + assert.step('edit custom'); return $.when(true); } return this._super.apply(this, arguments); @@ -155,7 +156,7 @@ QUnit.test('basic functionality, with one sub action', function (assert) { form.$('.oe_fold').click(); assert.ok(form.$('.oe_content').is(':visible'), "content is visible again"); - assert.verifySteps(['load action', 'add custom', 'add custom']); + assert.verifySteps(['load action', 'edit custom', 'edit custom']); assert.strictEqual($('.modal').length, 0, "should have no modal open"); @@ -183,7 +184,7 @@ QUnit.test('basic functionality, with one sub action', function (assert) { assert.strictEqual($('.modal').length, 0, "should have no modal open"); assert.strictEqual(form.$('.oe_action').length, 0, "should have no displayed action"); - assert.verifySteps(['load action', 'add custom', 'add custom', 'add custom', 'add custom']); + assert.verifySteps(['load action', 'edit custom', 'edit custom', 'edit custom', 'edit custom']); form.destroy(); }); @@ -292,8 +293,8 @@ QUnit.test('can drag and drop a view', function (assert) { views: [[4, 'list']], }); } - if (route === '/web/view/add_custom') { - assert.step('add custom'); + if (route === '/web/view/edit_custom') { + assert.step('edit custom'); return $.when(true); } return this._super.apply(this, arguments); @@ -339,8 +340,8 @@ QUnit.test('twice the same action in a dashboard', function (assert) { views: [[4, 'list'],[5, 'kanban']], }); } - if (route === '/web/view/add_custom') { - assert.step('add custom'); + if (route === '/web/view/edit_custom') { + assert.step('edit custom'); return $.when(true); } return this._super.apply(this, arguments); @@ -454,5 +455,48 @@ QUnit.test('clicking on a kanban\'s button should trigger the action', function form.destroy(); }); +QUnit.test('subviews are aware of attach in or detach from the DOM', function (assert) { + assert.expect(2); + + // patch list renderer `on_attach_callback` for the test only + testUtils.patch(ListRenderer, { + on_attach_callback: function () { + assert.step('subview on_attach_callback'); + } + }); + + var form = createView({ + View: FormView, + model: 'board', + data: this.data, + arch: '
' + + '' + + '' + + '' + + '' + + '' + + '
', + mockRPC: function (route) { + if (route === '/web/action/load') { + return $.when({ + res_model: 'partner', + views: [[4, 'list']], + }); + } + return this._super.apply(this, arguments); + }, + archs: { + 'partner,4,list': + '', + }, + }); + + assert.verifySteps(['subview on_attach_callback']); + + // restore on_attach_callback of ListRenderer + testUtils.unpatch(ListRenderer); + + form.destroy(); +}); }); diff --git a/addons/http_routing/__init__.py b/addons/http_routing/__init__.py index 013872cc..b8554620 100644 --- a/addons/http_routing/__init__.py +++ b/addons/http_routing/__init__.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- # Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. +from . import controllers from . import models diff --git a/addons/http_routing/controllers/__init__.py b/addons/http_routing/controllers/__init__.py new file mode 100644 index 00000000..d011bb1c --- /dev/null +++ b/addons/http_routing/controllers/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. + +from . import main diff --git a/addons/http_routing/controllers/main.py b/addons/http_routing/controllers/main.py new file mode 100644 index 00000000..fa2de823 --- /dev/null +++ b/addons/http_routing/controllers/main.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. + +import flectra + +from flectra import http +from flectra.http import request +from flectra.osv import expression +from flectra.addons.web.controllers.main import WebClient, Home + +class Routing(Home): + + @http.route('/website/translations', type='json', auth="public", website=True) + def get_website_translations(self, lang, mods=None): + Modules = request.env['ir.module.module'].sudo() + IrHttp = request.env['ir.http'].sudo() + domain = IrHttp._get_translation_frontend_modules_domain() + modules = Modules.search( + expression.AND([domain, [('state', '=', 'installed')]]) + ).mapped('name') + if mods: + modules += mods + return WebClient().translations(mods=modules, lang=lang) diff --git a/addons/http_routing/geoipresolver.py b/addons/http_routing/geoipresolver.py new file mode 100644 index 00000000..24bbe723 --- /dev/null +++ b/addons/http_routing/geoipresolver.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +import os.path + +try: + import GeoIP # Legacy +except ImportError: + GeoIP = None + +try: + import geoip2 + import geoip2.database +except ImportError: + geoip2 = None + +class GeoIPResolver(object): + def __init__(self, fname): + self.fname = fname + try: + self._db = geoip2.database.Reader(fname) + self.version = 2 + except Exception: + try: + self._db = GeoIP.open(fname, GeoIP.GEOIP_STANDARD) + self.version = 1 + assert self._db.database_info is not None + except Exception: + raise ValueError('Invalid GeoIP database: %r' % fname) + + def __del__(self): + if self.version == 2: + self._db.close() + + @classmethod + def open(cls, fname): + if not GeoIP and not geoip2: + return None + if not os.path.exists(fname): + return None + return GeoIPResolver(fname) + + def resolve(self, ip): + if self.version == 1: + return self._db.record_by_addr(ip) or {} + elif self.version == 2: + try: + r = self._db.city(ip) + except (ValueError, geoip2.errors.AddressNotFoundError): + return {} + # Compatibility with Legacy database. + # Some ips cannot be located to a specific country. Legacy DB used to locate them in + # continent instead of country. Do the same to not change behavior of existing code. + country, attr = (r.country, 'iso_code') if r.country.geoname_id else (r.continent, 'code') + return { + 'city': r.city.name, + 'country_code': getattr(country, attr), + 'country_name': country.name, + 'region': r.subdivisions[0].iso_code if r.subdivisions else None, + 'time_zone': r.location.time_zone, + } + + # compat + def record_by_addr(self, addr): + return self.resolve(addr) diff --git a/addons/http_routing/models/ir_http.py b/addons/http_routing/models/ir_http.py index 1b058b8d..ebd80949 100644 --- a/addons/http_routing/models/ir_http.py +++ b/addons/http_routing/models/ir_http.py @@ -18,6 +18,8 @@ from flectra.addons.base.ir.ir_http import RequestUID, ModelConverter from flectra.http import request from flectra.tools import config, ustr, pycompat +from ..geoipresolver import GeoIPResolver + _logger = logging.getLogger(__name__) # global resolver (GeoIP API is thread-safe, for multithreaded workers) @@ -229,6 +231,13 @@ class IrHttp(models.AbstractModel): return request.env['res.lang'].search([('code', '=', lang_code)], limit=1) return request.env['res.lang'].search([], limit=1) + @classmethod + def _get_translation_frontend_modules_domain(cls): + """ Return a domain to list the domain adding web-translations and + dynamic resources that may be used frontend views + """ + return [] + bots = "bot|crawl|slurp|spider|curl|wget|facebookexternalhit".split("|") @classmethod @@ -258,25 +267,18 @@ class IrHttp(models.AbstractModel): # Lazy init of GeoIP resolver if flectra._geoip_resolver is not None: return + geofile = config.get('geoip_database') try: - import GeoIP - # updated database can be downloaded on MaxMind website - # http://dev.maxmind.com/geoip/legacy/install/city/ - geofile = config.get('geoip_database') - if os.path.exists(geofile): - flectra._geoip_resolver = GeoIP.open(geofile, GeoIP.GEOIP_STANDARD) - else: - flectra._geoip_resolver = False - _logger.warning('GeoIP database file %r does not exists, apt-get install geoip-database-contrib or download it from http://dev.maxmind.com/geoip/legacy/install/city/', geofile) - except ImportError: - flectra._geoip_resolver = False + flectra._geoip_resolver = GeoIPResolver.open(geofile) or False + except Exception as e: + _logger.warning('Cannot load GeoIP: %s', ustr(e)) @classmethod def _geoip_resolve(cls): if 'geoip' not in request.session: record = {} if flectra._geoip_resolver and request.httprequest.remote_addr: - record = flectra._geoip_resolver.record_by_addr(request.httprequest.remote_addr) or {} + record = flectra._geoip_resolver.resolve(request.httprequest.remote_addr) or {} request.session['geoip'] = record @classmethod diff --git a/addons/l10n_fr_pos_cert/data/pos_inalterability.xml b/addons/l10n_fr_pos_cert/data/pos_inalterability.xml index 98c78157..e71876ee 100644 --- a/addons/l10n_fr_pos_cert/data/pos_inalterability.xml +++ b/addons/l10n_fr_pos_cert/data/pos_inalterability.xml @@ -8,6 +8,7 @@ Pos Orders Inalterability Check ir.actions.server + code action = env['pos.order']._check_hash_integrity(env.user.company_id.id) diff --git a/addons/l10n_fr_pos_cert/models/account_bank_statement.py b/addons/l10n_fr_pos_cert/models/account_bank_statement.py index bc756bef..94ba807a 100644 --- a/addons/l10n_fr_pos_cert/models/account_bank_statement.py +++ b/addons/l10n_fr_pos_cert/models/account_bank_statement.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. -from flectra import models +from flectra import models, api from flectra.tools.translate import _ from flectra.exceptions import UserError @@ -8,6 +8,7 @@ from flectra.exceptions import UserError class AccountBankStatement(models.Model): _inherit = 'account.bank.statement' + @api.multi def unlink(self): for statement in self.filtered(lambda s: s.company_id._is_accounting_unalterable() and s.journal_id.journal_user): raise UserError(_('You cannot modify anything on a bank statement (name: %s) that was created by point of sale operations.') % (statement.name,)) @@ -17,6 +18,7 @@ class AccountBankStatement(models.Model): class AccountBankStatementLine(models.Model): _inherit = 'account.bank.statement.line' + @api.multi def unlink(self): for line in self.filtered(lambda s: s.company_id._is_accounting_unalterable() and s.journal_id.journal_user): raise UserError(_('You cannot modify anything on a bank statement line (name: %s) that was created by point of sale operations.') % (line.name,)) diff --git a/addons/l10n_generic_coa/__init__.py b/addons/l10n_generic_coa/__init__.py index 7eb13095..d6715ec4 100644 --- a/addons/l10n_generic_coa/__init__.py +++ b/addons/l10n_generic_coa/__init__.py @@ -1,2 +1,8 @@ # -*- coding: utf-8 -*- # Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. + + +def uninstall_hook(cr, registry): + cr.execute( + "DELETE FROM ir_model_data WHERE module = 'l10n_generic_coa'" + ) diff --git a/addons/l10n_generic_coa/__manifest__.py b/addons/l10n_generic_coa/__manifest__.py index 408bd555..19a3f3a1 100644 --- a/addons/l10n_generic_coa/__manifest__.py +++ b/addons/l10n_generic_coa/__manifest__.py @@ -29,4 +29,5 @@ Install some generic chart of accounts. '../account/demo/account_invoice_demo.yml', ], 'website': 'https://flectrahq.com/page/accounting', + 'uninstall_hook': 'uninstall_hook', } diff --git a/addons/l10n_in/views/report_invoice.xml b/addons/l10n_in/views/report_invoice.xml index d41dccbc..028748cc 100644 --- a/addons/l10n_in/views/report_invoice.xml +++ b/addons/l10n_in/views/report_invoice.xml @@ -98,7 +98,7 @@ - + diff --git a/addons/note/static/description/index.html b/addons/note/static/description/index.html index 2e02de80..83dd28df 100644 --- a/addons/note/static/description/index.html +++ b/addons/note/static/description/index.html @@ -48,7 +48,7 @@

- Odoo Notes helps you stay organized and focused on your work with a structured and accessible system. Break down your tasks and projects into actionable work items and achieve your goals more efficiently + Flectra Notes helps you stay organized and focused on your work with a structured and accessible system. Break down your tasks and projects into actionable work items and achieve your goals more efficiently

@@ -78,7 +78,7 @@

Flectra Notes adapts to your needs and habits

- Odoo Notes' smart kanban view allows every user to customize their own steps in order to process their to-do lists and notes. Choose the default template or create your own steps/stages of the process. + Flectra Notes' smart kanban view allows every user to customize their own steps in order to process their to-do lists and notes. Choose the default template or create your own steps/stages of the process.

diff --git a/addons/note/views/note_views.xml b/addons/note/views/note_views.xml index 6503f20b..c18d94e8 100644 --- a/addons/note/views/note_views.xml +++ b/addons/note/views/note_views.xml @@ -114,7 +114,7 @@
-
+
diff --git a/addons/pad/models/pad.py b/addons/pad/models/pad.py index 07758fe8..9d264850 100644 --- a/addons/pad/models/pad.py +++ b/addons/pad/models/pad.py @@ -10,7 +10,6 @@ import requests from flectra import api, models, _ from flectra.exceptions import UserError -from flectra.tools import html2plaintext from ..py_etherpad import EtherpadLiteClient @@ -76,7 +75,7 @@ class PadCommon(models.AbstractModel): @api.model def pad_get_content(self, url): company = self.env.user.sudo().company_id - myPad = EtherpadLiteClient(company.pad_key, company.pad_server + '/api') + myPad = EtherpadLiteClient(company.pad_key, (company.pad_server or '') + '/api') content = '' if url: split_url = url.split('/p/') @@ -133,7 +132,7 @@ class PadCommon(models.AbstractModel): for k, field in self._fields.items(): if hasattr(field, 'pad_content_field') and vals.get(field.pad_content_field) and self[k]: company = self.env.user.sudo().company_id - myPad = EtherpadLiteClient(company.pad_key, company.pad_server + '/api') + myPad = EtherpadLiteClient(company.pad_key, (company.pad_server or '') + '/api') path = self[k].split('/p/')[1] myPad.setHtmlFallbackText(path, vals[field.pad_content_field]) diff --git a/addons/pad/static/plugin/ep_disable_init_focus/package.json b/addons/pad/static/plugin/ep_disable_init_focus/package.json index c54612a9..e6f307d1 100644 --- a/addons/pad/static/plugin/ep_disable_init_focus/package.json +++ b/addons/pad/static/plugin/ep_disable_init_focus/package.json @@ -9,7 +9,7 @@ "node":"*" }, "author":{ - "name":"Flectra S.A. - Hitesh Trivedi", + "name":"Odoo S.A. - Hitesh Trivedi", "email":"thiteshm155@gmail.com" } } diff --git a/addons/web/Gruntfile.js b/addons/web/Gruntfile.js index a4dc4034..43b58c3b 100644 --- a/addons/web/Gruntfile.js +++ b/addons/web/Gruntfile.js @@ -17,4 +17,4 @@ module.exports = function(grunt) { grunt.registerTask('default', ['jshint']); -}; \ No newline at end of file +}; \ No newline at end of file diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index 4c547f44..720e2692 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -28,6 +28,7 @@ import werkzeug.wsgi from collections import OrderedDict from werkzeug.urls import url_decode, iri_to_uri from xml.etree import ElementTree +import unicodedata import flectra @@ -1007,14 +1008,17 @@ class DataSet(http.Controller): class View(http.Controller): - @http.route('/web/view/add_custom', type='json', auth="user") - def add_custom(self, view_id, arch): - CustomView = request.env['ir.ui.view.custom'] - CustomView.create({ - 'user_id': request.session.uid, - 'ref_id': view_id, - 'arch': arch - }) + @http.route('/web/view/edit_custom', type='json', auth="user") + def edit_custom(self, custom_id, arch): + """ + Edit a custom view + + :param int custom_id: the id of the edited custom view + :param str arch: the edited arch of the custom view + :returns: dict with acknowledged operation (result set to True) + """ + custom_view = request.env['ir.ui.view.custom'].browse(custom_id) + custom_view.write({ 'arch': arch }) return {'result': True} class Binary(http.Controller): @@ -1039,7 +1043,7 @@ class Binary(http.Controller): '/web/content////'], type='http', auth="public") def content_common(self, xmlid=None, model='ir.attachment', id=None, field='datas', filename=None, filename_field='datas_fname', unique=None, mimetype=None, - download=None, data=None, token=None, access_token=None): + download=None, data=None, token=None, access_token=None, **kw): status, headers, content = binary_content( xmlid=xmlid, model=model, id=id, field=field, unique=unique, filename=filename, filename_field=filename_field, download=download, mimetype=mimetype, @@ -1089,8 +1093,12 @@ class Binary(http.Controller): elif status != 200 and download: return request.not_found() - height = int(height or 0) - width = int(width or 0) + if headers and dict(headers).get('Content-Type', '') == 'image/svg+xml': # we shan't resize svg images + height = 0 + width = 0 + else: + height = int(height or 0) + width = int(width or 0) if crop and (width or height): content = crop_image(content, type='center', size=(width, height), ratio=(1, 1)) @@ -1153,20 +1161,27 @@ class Binary(http.Controller): """ args = [] for ufile in files: + + filename = ufile.filename + if request.httprequest.user_agent.browser == 'safari': + # Safari sends NFD UTF-8 (where é is composed by 'e' and [accent]) + # we need to send it the same stuff, otherwise it'll fail + filename = unicodedata.normalize('NFD', ufile.filename) + try: attachment = Model.create({ - 'name': ufile.filename, + 'name': filename, 'datas': base64.encodestring(ufile.read()), - 'datas_fname': ufile.filename, + 'datas_fname': filename, 'res_model': model, 'res_id': int(id) }) except Exception: - args = args.append({'error': _("Something horrible happened")}) + args.append({'error': _("Something horrible happened")}) _logger.exception("Fail to upload attachment %s" % ufile.filename) else: args.append({ - 'filename': ufile.filename, + 'filename': filename, 'mimetype': ufile.content_type, 'id': attachment.id }) @@ -1428,7 +1443,7 @@ class ExportFormat(object): model, fields, ids, domain, import_compat = \ operator.itemgetter('model', 'fields', 'ids', 'domain', 'import_compat')(params) - Model = request.env[model].with_context(**params.get('context', {})) + Model = request.env[model].with_context(import_compat=import_compat, **params.get('context', {})) records = Model.browse(ids) or Model.search(domain, offset=0, limit=False, order=False) if not Model._is_an_ordinary_table(): diff --git a/addons/web/models/ir_qweb.py b/addons/web/models/ir_qweb.py index a7a5a11f..a3c89258 100644 --- a/addons/web/models/ir_qweb.py +++ b/addons/web/models/ir_qweb.py @@ -2,6 +2,7 @@ # Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. import hashlib +from collections import OrderedDict from flectra import api, models from flectra.tools import pycompat @@ -56,6 +57,24 @@ class Image(models.AbstractModel): elif options.get('zoom'): src_zoom = options['zoom'] - img = '' % \ - (classes, src, options.get('style', ''), ' alt="%s"' % alt if alt else '', ' data-zoom="1" data-zoom-image="%s"' % src_zoom if src_zoom else '') - return pycompat.to_text(img) + atts = OrderedDict() + atts["src"] = src + atts["class"] = classes + atts["style"] = options.get('style') + atts["alt"] = alt + atts["data-zoom"] = src_zoom and u'1' or None + atts["data-zoom-image"] = src_zoom + + atts = self.env['ir.qweb']._post_processing_att('img', atts, options.get('template_options')) + + img = ['') + + return u''.join(img) diff --git a/addons/web/static/lib/bootstrap/less/scaffolding.less b/addons/web/static/lib/bootstrap/less/scaffolding.less index d8f90cd6..2360561e 100644 --- a/addons/web/static/lib/bootstrap/less/scaffolding.less +++ b/addons/web/static/lib/bootstrap/less/scaffolding.less @@ -170,4 +170,4 @@ hr { [role="button"] { cursor: pointer; -} \ No newline at end of file +} \ No newline at end of file diff --git a/addons/web/static/lib/qweb/qweb-test-widgets.xml b/addons/web/static/lib/qweb/qweb-test-widgets.xml new file mode 100644 index 00000000..503880bc --- /dev/null +++ b/addons/web/static/lib/qweb/qweb-test-widgets.xml @@ -0,0 +1,26 @@ + + + {"value": "1988-09-16"} + 1988-09-16 + + + {"value": "1988-09-16 14:00:00"} + 1988-09-16 14:00:00 + + + {"value": "1988-09-16 14:00:00"} + 09/16/1988 16:00:00 + + + {"value": "1988-09-16 14:00:00"} + 09/16/1988 + + + {"value": "1988-09-16 01:00:00"} + 09/16/1988 + + + {"value": "1988-09-16 23:00:00"} + 09/17/1988 + + diff --git a/addons/web/static/src/js/chrome/abstract_web_client.js b/addons/web/static/src/js/chrome/abstract_web_client.js index 3cbfea51..82643172 100644 --- a/addons/web/static/src/js/chrome/abstract_web_client.js +++ b/addons/web/static/src/js/chrome/abstract_web_client.js @@ -165,7 +165,7 @@ var AbstractWebClient = Widget.extend(mixins.ServiceProvider, { core.bus.on('connection_restored', this, this.on_connection_restored); // crash manager integration - session.on('error', crash_manager, crash_manager.rpc_error); + core.bus.on('rpc_error', crash_manager, crash_manager.rpc_error); window.onerror = function (message, file, line, col, error) { // Scripts injected in DOM (eg: google API's js files) won't return a clean error on window.onerror. // The browser will just give you a 'Script error.' as message and nothing else for security issue. diff --git a/addons/web/static/src/js/chrome/loading.js b/addons/web/static/src/js/chrome/loading.js index 3351651d..5418168b 100644 --- a/addons/web/static/src/js/chrome/loading.js +++ b/addons/web/static/src/js/chrome/loading.js @@ -26,9 +26,9 @@ var Loading = Widget.extend({ this._super(parent); this.count = 0; this.blocked_ui = false; - session.on("request", this, this.request_call); - session.on("response", this, this.response_call); - session.on("response_failed", this, this.response_call); + core.bus.on('rpc_request', this, this.request_call); + core.bus.on("rpc_response", this, this.response_call); + core.bus.on("rpc_response_failed", this, this.response_call); }, destroy: function() { this.on_rpc_event(-this.count); diff --git a/addons/web/static/src/js/chrome/menu.js b/addons/web/static/src/js/chrome/menu.js index 2e1f34b7..6f31f54d 100644 --- a/addons/web/static/src/js/chrome/menu.js +++ b/addons/web/static/src/js/chrome/menu.js @@ -267,11 +267,14 @@ var Menu = Widget.extend({ * @param {Number} id the action_id to match * @param {Number} [menuID] a menu ID that may match with provided action */ - open_action: function (id) { + open_action: function (id, menuID) { var $menu = this.$el.add(this.$secondary_menus).find('a[data-action-id="' + id + '"]'); - var menu_id = $menu.data('menu'); - if (menu_id) { - this.open_menu(menu_id); + if (!(menuID && $menu.filter("[data-menu='" + menuID + "']").length)) { + // menuID doesn't match action, so pick first menu_item + menuID = $menu.data('menu'); + } + if (menuID) { + this.open_menu(menuID); } }, /** diff --git a/addons/web/static/src/js/chrome/view_manager.js b/addons/web/static/src/js/chrome/view_manager.js index 7f0cb2a8..e35226a7 100644 --- a/addons/web/static/src/js/chrome/view_manager.js +++ b/addons/web/static/src/js/chrome/view_manager.js @@ -263,7 +263,7 @@ var ViewManager = Widget.extend(ControlPanelMixin, { self.active_view = view; - if (!view.loaded) { + if (!view.loaded || view.loaded.state() === 'rejected') { view_options = _.extend({}, view.options, view_options, self.env); view.loaded = $.Deferred(); self.create_view(view, view_options).then(function(controller) { @@ -638,7 +638,7 @@ var ViewManager = Widget.extend(ControlPanelMixin, { * active controller. * * @private - * @param {OdooEvent} ev + * @param {FlectraEvent} ev * @param {function} ev.data.callback used to send the requested context */ _onGetControllerContext: function (ev) { diff --git a/addons/web/static/src/js/chrome/web_client.js b/addons/web/static/src/js/chrome/web_client.js index c4b5931e..22514ed7 100644 --- a/addons/web/static/src/js/chrome/web_client.js +++ b/addons/web/static/src/js/chrome/web_client.js @@ -103,6 +103,10 @@ return AbstractWebClient.extend({ bind_hashchange: function() { var self = this; $(window).bind('hashchange', this.on_hashchange); + var didHashChanged = false; + $(window).one('hashchange', function () { + didHashChanged = true; + }); var state = $.bbq.getState(true); if (_.isEmpty(state) || state.action === "login") { @@ -113,6 +117,9 @@ return AbstractWebClient.extend({ args: [[session.uid], ['action_id']], }) .done(function(result) { + if (didHashChanged) { + return; + } var data = result[0]; if(data.action_id) { self.action_manager.do_action(data.action_id[0]); diff --git a/addons/web/static/src/js/core/ajax.js b/addons/web/static/src/js/core/ajax.js index 9facc3a5..4442320c 100644 --- a/addons/web/static/src/js/core/ajax.js +++ b/addons/web/static/src/js/core/ajax.js @@ -5,7 +5,12 @@ var core = require('web.core'); var utils = require('web.utils'); var time = require('web.time'); -function genericJsonRpc (fct_name, params, fct) { +function genericJsonRpc (fct_name, params, settings, fct) { + var shadow = settings.shadow || false; + delete settings.shadow; + if (! shadow) + core.bus.trigger('rpc_request'); + var data = { jsonrpc: "2.0", method: fct_name, @@ -30,11 +35,49 @@ function genericJsonRpc (fct_name, params, fct) { }); // FIXME: jsonp? result.abort = function () { if (xhr.abort) xhr.abort(); }; - return result; + + var p = result.then(function (result) { + if (!shadow) { + core.bus.trigger('rpc_response'); + } + return result; + }, function (type, error, textStatus, errorThrown) { + if (type === "server") { + if (!shadow) { + core.bus.trigger('rpc_response'); + } + if (error.code === 100) { + core.bus.trigger('invalidate_session'); + } + return $.Deferred().reject(error, $.Event()); + } else { + if (!shadow) { + core.bus.trigger('rpc_response_failed'); + } + var nerror = { + code: -32098, + message: "XmlHttpRequestError " + errorThrown, + data: { + type: "xhr"+textStatus, + debug: error.responseText, + objects: [error, errorThrown] + }, + }; + return $.Deferred().reject(nerror, $.Event()); + } + }); + return p.fail(function () { // Allow deferred user to disable rpc_error call in fail + p.fail(function (error, event) { + if (!event.isDefaultPrevented()) { + core.bus.trigger('rpc_error', error, event); + } + }); + }); } function jsonRpc(url, fct_name, params, settings) { - return genericJsonRpc(fct_name, params, function(data) { + settings = settings || {}; + return genericJsonRpc(fct_name, params, settings, function(data) { return $.ajax(url, _.extend({}, settings, { url: url, dataType: 'json', @@ -47,7 +90,7 @@ function jsonRpc(url, fct_name, params, settings) { function jsonpRpc(url, fct_name, params, settings) { settings = settings || {}; - return genericJsonRpc(fct_name, params, function(data) { + return genericJsonRpc(fct_name, params, settings, function(data) { var payload_str = JSON.stringify(data, time.date_to_utc); var payload_url = $.param({r:payload_str}); var force2step = settings.force2step || false; @@ -260,8 +303,20 @@ function get_file(options) { if (options.error) { var body = this.contentDocument.body; var nodes = body.children.length === 0 ? body.childNodes : body.children; - var node = nodes[1] || nodes[0]; - options.error(JSON.parse(node.textContent)); + var errorParams = {}; + + try { // Case of a serialized Flectra Exception: It is Json Parsable + var node = nodes[1] || nodes[0]; + errorParams = JSON.parse(node.textContent); + } catch (e) { // Arbitrary uncaught python side exception + errorParams = { + message: nodes.length > 1 ? nodes[1].textContent : '', + data: { + title: nodes.length > 0 ? nodes[0].textContent : '', + } + } + } + options.error(errorParams); } } finally { complete(); diff --git a/addons/web/static/src/js/core/rpc.js b/addons/web/static/src/js/core/rpc.js index 55a4c17d..706c9343 100644 --- a/addons/web/static/src/js/core/rpc.js +++ b/addons/web/static/src/js/core/rpc.js @@ -52,19 +52,19 @@ return { if (options.method === 'read_group') { if (!(params.args && params.args[0] !== undefined)) { - params.kwargs.domain = options.domain || params.domain || params.kwargs.domain || []; + params.kwargs.domain = options.domain || params.domain || params.kwargs.domain || []; } if (!(params.args && params.args[1] !== undefined)) { - params.kwargs.fields = options.fields || params.fields || params.kwargs.fields || []; + params.kwargs.fields = options.fields || params.fields || params.kwargs.fields || []; } if (!(params.args && params.args[2] !== undefined)) { - params.kwargs.groupby = options.groupBy || params.groupBy || params.kwargs.groupby || []; + params.kwargs.groupby = options.groupBy || params.groupBy || params.kwargs.groupby || []; } - params.kwargs.offset = options.offset || params.offset || params.kwargs.offset; - params.kwargs.limit = options.limit || params.limit || params.kwargs.limit; + params.kwargs.offset = options.offset || params.offset || params.kwargs.offset; + params.kwargs.limit = options.limit || params.limit || params.kwargs.limit; // In kwargs, we look for "orderby" rather than "orderBy" (note the absence of capital B), // since the Python argument to the actual function is "orderby". - var orderBy = options.orderBy || params.orderBy || params.kwargs.orderby; + var orderBy = options.orderBy || params.orderBy || params.kwargs.orderby; params.kwargs.orderby = orderBy ? this._serializeSort(orderBy) : orderBy; params.kwargs.lazy = 'lazy' in options ? options.lazy : params.lazy; } @@ -72,7 +72,7 @@ return { if (options.method === 'search_read') { // call the model method params.kwargs.domain = options.domain || params.domain || params.kwargs.domain; - params.kwargs.fields = options.fields || params.fields || params.kwargs.fields; + params.kwargs.fields = options.fields || params.fields || params.kwargs.fields; params.kwargs.offset = options.offset || params.offset || params.kwargs.offset; params.kwargs.limit = options.limit || params.limit || params.kwargs.limit; // In kwargs, we look for "order" rather than "orderBy" since the Python diff --git a/addons/web/static/src/js/core/session.js b/addons/web/static/src/js/core/session.js index dae2fc50..9ef37dc9 100644 --- a/addons/web/static/src/js/core/session.js +++ b/addons/web/static/src/js/core/session.js @@ -49,6 +49,7 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, { this.qweb_mutex = new concurrency.Mutex(); this.currencies = {}; this._groups_def = {}; + core.bus.on('invalidate_session', this, this._onInvalidateSession); }, setup: function (origin, options) { // must be able to customize server @@ -327,23 +328,17 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, { rpc: function (url, params, options) { var self = this; options = _.clone(options || {}); - var shadow = options.shadow || false; options.headers = _.extend({}, options.headers); if (flectra.debug) { options.headers["X-Debug-Mode"] = $.deparam($.param.querystring()).debug; } - delete options.shadow; - return self.check_session_id().then(function () { // TODO: remove if (! _.isString(url)) { _.extend(options, url); url = url.url; } - // TODO correct handling of timeouts - if (! shadow) - self.trigger('request'); var fct; if (self.origin_server) { fct = ajax.jsonRpc; @@ -362,37 +357,7 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, { url = self.url(url, null); options.session_id = self.session_id || ''; } - var p = fct(url, "call", params, options); - p = p.then(function (result) { - if (! shadow) - self.trigger('response'); - return result; - }, function (type, error, textStatus, errorThrown) { - if (type === "server") { - if (! shadow) - self.trigger('response'); - if (error.code === 100) { - self.uid = false; - } - return $.Deferred().reject(error, $.Event()); - } else { - if (! shadow) - self.trigger('response_failed'); - var nerror = { - code: -32098, - message: "XmlHttpRequestError " + errorThrown, - data: {type: "xhr"+textStatus, debug: error.responseText, objects: [error, errorThrown] } - }; - return $.Deferred().reject(nerror, $.Event()); - } - }); - return p.fail(function () { // Allow deferred user to disable rpc_error call in fail - p.fail(function (error, event) { - if (!event.isDefaultPrevented()) { - self.trigger('error', error, event); - } - }); - }); + return fct(url, "call", params, options); }); }, url: function (path, params) { @@ -418,6 +383,17 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, { getTZOffset: function (date) { return -new Date(date).getTimezoneOffset(); }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onInvalidateSession: function () { + this.uid = false; + }, }); return Session; diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js index 3ae69e3d..09aec6f0 100644 --- a/addons/web/static/src/js/fields/basic_fields.js +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -468,7 +468,13 @@ var FieldDate = InputField.extend({ * @private */ _makeDatePicker: function () { - return new datepicker.DateWidget(this, {defaultDate: this.value}); + return new datepicker.DateWidget( + this, + _.defaults( + this.nodeOptions.datepicker || {}, + {defaultDate: this.value} + ) + ); }, /** @@ -1013,6 +1019,20 @@ var FieldPhone = FieldEmail.extend({ this.$el.removeClass('o_text_overflow'); }, + /** + * Remove possibly present ­ characters when saving number + * + * @override + * @private + */ + _setValue: function (value, options) { + if (value) { + // remove possibly pasted ­ characters + value = value.replace(/\u00AD/g, ''); + } + return this._super(value, options); + }, + /** * Phone fields are clickable in readonly on small screens ~= on phones. * This can be overriden by call-capable modules to display a clickable @@ -1068,6 +1088,7 @@ var UrlWidget = InputField.extend({ _renderReadonly: function () { this.$el.text(this.attrs.text || this.value) .addClass('o_form_uri o_text_overflow') + .attr('target', '_blank') .attr('href', this.value); } }); @@ -1211,9 +1232,6 @@ var FieldBinaryImage = AbstractFieldBinary.extend({ self.do_warn(_t("Image"), _t("Could not display the selected image.")); }); }, - isSet: function () { - return true; - }, }); var FieldBinaryFile = AbstractFieldBinary.extend({ @@ -2412,6 +2430,21 @@ var AceEditor = DebouncedField.extend({ // Private //-------------------------------------------------------------------------- + /** + * Format value + * + * Note: We have to overwrite this method to always return a string. + * AceEditor works with string and not boolean value. + * + * @override + * @private + * @param {boolean|string} value + * @returns {string} + */ + _formatValue: function (value) { + return this._super.apply(this, arguments) || ''; + }, + /** * @override * @private @@ -2434,6 +2467,7 @@ var AceEditor = DebouncedField.extend({ this.aceSession.setValue(newValue); } }, + /** * Starts the ace library on the given DOM element. This initializes the * ace editor option according to the edit/readonly mode and binds ace diff --git a/addons/web/static/src/js/fields/field_registry.js b/addons/web/static/src/js/fields/field_registry.js index 8e710048..a9c42937 100644 --- a/addons/web/static/src/js/fields/field_registry.js +++ b/addons/web/static/src/js/fields/field_registry.js @@ -26,6 +26,7 @@ registry .add('datetime', basic_fields.FieldDateTime) .add('domain', basic_fields.FieldDomain) .add('text', basic_fields.FieldText) + .add('html', basic_fields.FieldText) .add('float', basic_fields.FieldFloat) .add('char', basic_fields.FieldChar) .add('link_button', basic_fields.LinkButton) diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js index 6bdfcccf..33c7c4ad 100644 --- a/addons/web/static/src/js/fields/relational_fields.js +++ b/addons/web/static/src/js/fields/relational_fields.js @@ -340,8 +340,8 @@ var FieldMany2One = AbstractField.extend({ _renderReadonly: function () { var value = _.escape((this.m2o_value || "").trim()).split("\n").join("
"); this.$el.html(value); - if (!this.nodeOptions.no_open) { - this.$el.attr('href', '#'); + if (!this.nodeOptions.no_open && this.value) { + this.$el.attr('href', _.str.sprintf('#id=%s&model=%s', this.value.res_id, this.field.relation)); this.$el.addClass('o_form_uri'); } }, @@ -1029,7 +1029,7 @@ var FieldX2Many = AbstractField.extend({ */ _onEditLine: function (ev) { ev.stopPropagation(); - this.trigger_up('freeze_order', {id: this.value.id}); + this.trigger_up('edited_list', { id: this.value.id }); var editedRecord = this.value.data[ev.data.index]; this.renderer.setRowMode(editedRecord.id, 'edit') .done(ev.data.onSuccess); @@ -1117,7 +1117,7 @@ var FieldX2Many = AbstractField.extend({ _onResequence: function (event) { event.stopPropagation(); var self = this; - this.trigger_up('freeze_order', {id: this.value.id}); + this.trigger_up('edited_list', { id: this.value.id }); var rowIDs = event.data.rowIDs.slice(); var rowID = rowIDs.pop(); var defs = _.map(rowIDs, function (rowID, index) { @@ -1261,7 +1261,7 @@ var FieldOne2Many = FieldX2Many.extend({ } } else if (!this.creatingRecord) { this.creatingRecord = true; - this.trigger_up('freeze_order', {id: this.value.id}); + this.trigger_up('edited_list', { id: this.value.id }); this._setValue({ operation: 'CREATE', position: this.editable, @@ -1291,12 +1291,24 @@ var FieldOne2Many = FieldX2Many.extend({ // we don't want interference with the components upstream. ev.stopPropagation(); + var self = this; var id = ev.data.id; - // trigger an empty 'UPDATE' operation when the user clicks on 'Save' in - // the dialog, to notify the main record that a subrecord of this - // relational field has changed (those changes will be already stored on - // that subrecord, thanks to the 'Save'). - var onSaved = this._setValue.bind(this, { operation: 'UPDATE', id: id }, {}); + var onSaved = function (record) { + if (_.some(self.value.data, {id: record.id})) { + // the record already exists in the relation, so trigger an + // empty 'UPDATE' operation when the user clicks on 'Save' in + // the dialog, to notify the main record that a subrecord of + // this relational field has changed (those changes will be + // already stored on that subrecord, thanks to the 'Save'). + self._setValue({ operation: 'UPDATE', id: record.id }); + } else { + // the record isn't in the relation yet, so add it ; this can + // happen if the user clicks on 'Save & New' in the dialog (the + // opened record will be updated, and other records will be + // created) + self._setValue({ operation: 'ADD', id: record.id }); + } + }; this._openFormDialog({ id: id, on_saved: onSaved, @@ -1551,6 +1563,7 @@ var FieldMany2ManyBinaryMultiFiles = AbstractField.extend({ this.$('form.o_form_binary_form').submit(); this.$('.oe_fileupload').hide(); + ev.target.value = ""; }, /** * @private @@ -2265,6 +2278,21 @@ var FieldReference = FieldMany2One.extend({ return this._super.apply(this, arguments); }, + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + * @returns {jQuery} + */ + getFocusableElement: function () { + if (this.mode === 'edit' && !this.field.relation) { + return this.$('select'); + } + return this._super.apply(this, arguments); + }, + //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- diff --git a/addons/web/static/src/js/fields/upgrade_fields.js b/addons/web/static/src/js/fields/upgrade_fields.js index 827047e9..2fdda595 100644 --- a/addons/web/static/src/js/fields/upgrade_fields.js +++ b/addons/web/static/src/js/fields/upgrade_fields.js @@ -92,10 +92,11 @@ var AbstractFieldUpgrade = { */ _render: function () { this._super.apply(this, arguments); - this._insertEnterpriseLabel($("", { - text: "Enterprise", - 'class': "label label-primary oe_inline o_enterprise_label" - })); + // disable enterprise tags + // this._insertEnterpriseLabel($("", { + // text: "Enterprise", + // 'class': "label label-primary oe_inline o_enterprise_label" + // })); }, /** * This function is meant to be overriden to reset the $el to its initial diff --git a/addons/web/static/src/js/services/crash_manager.js b/addons/web/static/src/js/services/crash_manager.js index 8215697d..5f1d8757 100644 --- a/addons/web/static/src/js/services/crash_manager.js +++ b/addons/web/static/src/js/services/crash_manager.js @@ -41,7 +41,8 @@ var CrashManager = core.Class.extend({ core.bus.trigger('connection_lost'); this.connection_lost = true; var timeinterval = setInterval(function() { - ajax.jsonRpc('/web/webclient/version_info').then(function() { + var options = {shadow: true}; + ajax.jsonRpc('/web/webclient/version_info', 'call', {}, options).then(function () { clearInterval(timeinterval); core.bus.trigger('connection_restored'); self.connection_lost = false; @@ -103,7 +104,7 @@ var CrashManager = core.Class.extend({ if (!this.active) { return; } - new Dialog(this, { + return new Dialog(this, { size: 'medium', title: _.str.capitalize(error.type || error.message) || _t("Flectra Warning"), subtitle: error.data.title, diff --git a/addons/web/static/src/js/views/abstract_controller.js b/addons/web/static/src/js/views/abstract_controller.js index 13e97939..71072ce1 100644 --- a/addons/web/static/src/js/views/abstract_controller.js +++ b/addons/web/static/src/js/views/abstract_controller.js @@ -12,6 +12,7 @@ flectra.define('web.AbstractController', function (require) { * reading localstorage, ...) has to go through the controller. */ +var concurrency = require('web.concurrency'); var Widget = require('web.Widget'); @@ -41,6 +42,9 @@ var AbstractController = Widget.extend({ this.handle = params.handle; this.activeActions = params.activeActions; this.initialState = params.initialState; + + // use a DropPrevious to correctly handle concurrent updates + this.dp = new concurrency.DropPrevious(); }, /** * Simply renders and updates the url. @@ -62,6 +66,18 @@ var AbstractController = Widget.extend({ } return this._super.apply(this, arguments); }, + /** + * Called each time the controller is attached into the DOM. + */ + on_attach_callback: function () { + this.renderer.on_attach_callback(); + }, + /** + * Called each time the controller is detached from the DOM. + */ + on_detach_callback: function () { + this.renderer.on_detach_callback(); + }, //-------------------------------------------------------------------------- // Public @@ -194,11 +210,11 @@ var AbstractController = Widget.extend({ var self = this; var shouldReload = (options && 'reload' in options) ? options.reload : true; var def = shouldReload ? this.model.reload(this.handle, params) : $.when(); - return def.then(function (handle) { + return this.dp.add(def).then(function (handle) { self.handle = handle || self.handle; // update handle if we reloaded var state = self.model.get(self.handle); var localState = self.renderer.getLocalState(); - return self.renderer.updateState(state, params).then(function () { + return self.dp.add(self.renderer.updateState(state, params)).then(function () { self.renderer.setLocalState(localState); self._update(state); }); diff --git a/addons/web/static/src/js/views/abstract_renderer.js b/addons/web/static/src/js/views/abstract_renderer.js index 25f97b1e..19d1ee85 100644 --- a/addons/web/static/src/js/views/abstract_renderer.js +++ b/addons/web/static/src/js/views/abstract_renderer.js @@ -36,6 +36,14 @@ return Widget.extend({ this.$el.addClass(this.arch.attrs.class); return $.when(this._render(), this._super()); }, + /** + * Called each time the renderer is attached into the DOM. + */ + on_attach_callback: function () {}, + /** + * Called each time the renderer is detached from the DOM. + */ + on_detach_callback: function () {}, //-------------------------------------------------------------------------- // Public diff --git a/addons/web/static/src/js/views/abstract_view.js b/addons/web/static/src/js/views/abstract_view.js index 7f4aae7e..9a46a731 100644 --- a/addons/web/static/src/js/views/abstract_view.js +++ b/addons/web/static/src/js/views/abstract_view.js @@ -224,6 +224,14 @@ var AbstractView = Class.extend({ _.each(this.loadParams.fieldsInfo.form, function (attrs, fieldName) { var field = fields[fieldName]; + if (!field) { + // when a one2many record is opened in a form view, the fields + // of the main one2many view (list or kanban) are added to the + // fieldsInfo of its form view, but those fields aren't in the + // loadParams.fields, as they are not displayed in the view, so + // we can ignore them. + return; + } if (field.type !== 'one2many' && field.type !== 'many2many') { return; } diff --git a/addons/web/static/src/js/views/basic/basic_controller.js b/addons/web/static/src/js/views/basic/basic_controller.js index a13187aa..763aed14 100644 --- a/addons/web/static/src/js/views/basic/basic_controller.js +++ b/addons/web/static/src/js/views/basic/basic_controller.js @@ -306,7 +306,7 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { * * @private */ - _disableButtons: function () { + _disableButtons: function () { if (this.$buttons) { this.$buttons.find('button').attr('disabled', true); } @@ -338,10 +338,8 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { return; } self.model.discardChanges(recordID); - if (self.model.isNew(recordID)) { - if (self.model.canBeAbandoned(recordID)) { - self._abandonRecord(recordID); - } + if (self.model.canBeAbandoned(recordID)) { + self._abandonRecord(recordID); return; } return self._confirmSave(recordID); @@ -352,7 +350,7 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { * * @private */ - _enableButtons: function () { + _enableButtons: function () { if (this.$buttons) { this.$buttons.find('button').removeAttr('disabled'); } diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js index 25d67b45..52c2f783 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -347,6 +347,7 @@ var BasicModel = AbstractModel.extend({ var element = this.localData[id]; var isNew = this.isNew(id); var rollback = 'rollback' in options ? options.rollback : isNew; + var initialOffset = element.offset; this._visitChildren(element, function (elem) { if (rollback && elem._savePoint) { if (elem._savePoint instanceof Array) { @@ -365,6 +366,7 @@ var BasicModel = AbstractModel.extend({ delete elem.tempLimitIncrement; } }); + element.offset = initialOffset; }, /** * Duplicate a record (by calling the 'copy' route) @@ -375,11 +377,12 @@ var BasicModel = AbstractModel.extend({ duplicateRecord: function (recordID) { var self = this; var record = this.localData[recordID]; + var context = this._getContext(record); return this._rpc({ model: record.model, method: 'copy', args: [record.data.id], - context: this._getContext(record), + context: context, }) .then(function (res_id) { var index = record.res_ids.indexOf(record.res_id); @@ -391,6 +394,7 @@ var BasicModel = AbstractModel.extend({ res_id: res_id, res_ids: record.res_ids.slice(0), viewType: record.viewType, + context: context, }); }); }, @@ -567,10 +571,18 @@ var BasicModel = AbstractModel.extend({ return _t("New"); }, /** - * Returns true if a record can be abandoned from a list datapoint. + * Returns true if a record can be abandoned. * - * A record cannot be abandonned if it has been registered as "added" - * in the parent's savepoint, otherwise it can be abandonned. + * Case for not abandoning the record: + * + * 1. flagged as 'no abandon' (i.e. during a `default_get`, including any + * `onchange` from a `default_get`) + * 2. registered in a list on addition + * 2.1. registered as non-new addition + * 2.2. registered as new additon on update + * 3. record is not new + * + * Otherwise, the record can be abandoned. * * This is useful when discarding changes on this record, as it means that * we must keep the record even if some fields are invalids (e.g. required @@ -580,15 +592,29 @@ var BasicModel = AbstractModel.extend({ * @returns {boolean} */ canBeAbandoned: function (id) { - var data = this.localData[id]; - var parent = this.localData[data.parentID]; - var abandonable = true; - if (parent) { - abandonable = !_.some(parent._savePoint, function (entry) { - return entry.operation === 'ADD' && entry.id === id; - }); + // 1. no drop if flagged + if (this.localData[id]._noAbandon) { + return false; } - return abandonable; + // 2. no drop in a list on "ADD in some cases + var record = this.localData[id]; + var parent = this.localData[record.parentID]; + if (parent) { + var entry = _.findWhere(parent._savePoint, {operation: 'ADD', id: id}); + if (entry) { + // 2.1. no drop on non-new addition in list + if (!entry.isNew) { + return false; + } + // 2.2. no drop on new addition on "UPDATE" + var lastEntry = _.last(parent._savePoint); + if (lastEntry.operation === 'UPDATE' && lastEntry.id === id) { + return false; + } + } + } + // 3. drop new records + return this.isNew(id); }, /** * Returns true if a record is dirty. A record is considered dirty if it has @@ -874,6 +900,15 @@ var BasicModel = AbstractModel.extend({ // x2Many case: the new record has been stored in _changes, as a // command so we remove the command(s) related to that record parent._changes = _.filter(parent._changes, function (change) { + if (change.id === elementID && + change.operation === 'ADD' && // For now, only an ADD command increases limits + parent.tempLimitIncrement) { + // The record will be deleted from the _changes. + // So we won't be passing into the logic of _applyX2ManyOperations anymore + // implying that we have to cancel out the effects of an ADD command here + parent.tempLimitIncrement--; + parent.limit--; + } return change.id !== elementID; }); } else { @@ -949,11 +984,11 @@ var BasicModel = AbstractModel.extend({ * @returns {Deferred} * Resolved with the list of field names (whose value has been modified) */ - save: function (record_id, options) { + save: function (recordID, options) { var self = this; return this.mutex.exec(function () { options = options || {}; - var record = self.localData[record_id]; + var record = self.localData[recordID]; if (options.savePoint) { self._visitChildren(record, function (rec) { var newValue = rec._changes || rec.data; @@ -971,7 +1006,7 @@ var BasicModel = AbstractModel.extend({ }); } var shouldReload = 'reload' in options ? options.reload : true; - var method = self.isNew(record_id) ? 'create' : 'write'; + var method = self.isNew(recordID) ? 'create' : 'write'; if (record._changes) { // id never changes, and should not be written delete record._changes.id; @@ -1053,7 +1088,13 @@ var BasicModel = AbstractModel.extend({ addFieldsInfo: function (recordID, viewInfo) { var record = this.localData[recordID]; record.fields = _.extend({}, record.fields, viewInfo.fields); - record.fieldsInfo = _.extend({}, record.fieldsInfo, viewInfo.fieldsInfo); + // complete the given fieldsInfo with the fields of the main view, so + // that those field will be reloaded if a reload is triggered by the + // secondary view + var fieldsInfo = _.mapObject(viewInfo.fieldsInfo, function (fieldsInfo) { + return _.defaults({}, fieldsInfo, record.fieldsInfo[record.viewType]); + }); + record.fieldsInfo = _.extend({}, record.fieldsInfo, fieldsInfo); }, /** * For list resources, this freezes the current records order. @@ -1535,7 +1576,10 @@ var BasicModel = AbstractModel.extend({ rec = self._makeDataPoint(params); list._cache[rec.res_id] = rec.id; } - + // Do not abandon the record if it has been created + // from `default_get`. The list has a savepoint only + // after having fully executed `default_get`. + rec._noAbandon = !list._savePoint; list._changes.push({operation: 'ADD', id: rec.id}); if (command[0] === 1) { list._changes.push({operation: 'UPDATE', id: rec.id}); @@ -1953,6 +1997,7 @@ var BasicModel = AbstractModel.extend({ var records = []; var ids = []; list = this._applyX2ManyOperations(list); + _.each(list.data, function (localId) { var record = self.localData[localId]; var data = record._changes || record.data; @@ -1963,16 +2008,22 @@ var BasicModel = AbstractModel.extend({ ids.push(many2oneRecord.res_id); model = many2oneRecord.model; }); + if (!ids.length) { + return $.when(); + } return this._rpc({ model: model, method: 'name_get', - args: [ids], + args: [_.uniq(ids)], context: list.context, }) .then(function (name_gets) { - for (var i = 0; i < name_gets.length; i++) { - records[i].data.display_name = name_gets[i][1]; - } + _.each(records, function (record) { + var nameGet = _.find(name_gets, function (nameGet) { + return nameGet[0] === record.data.id; + }); + record.data.display_name = nameGet[1]; + }); }); }, /** @@ -1990,7 +2041,8 @@ var BasicModel = AbstractModel.extend({ */ _fetchRecord: function (record, options) { var self = this; - var fieldNames = options && options.fieldNames || record.getFieldNames(); + options = options || {}; + var fieldNames = options.fieldNames || record.getFieldNames(options); fieldNames = _.uniq(fieldNames.concat(['display_name'])); return this._rpc({ model: record.model, @@ -2457,7 +2509,16 @@ var BasicModel = AbstractModel.extend({ var self = this; var def; if (list.static) { - def = this._readUngroupedList(list); + def = this._readUngroupedList(list).then(function () { + if (list.parentID && self.isNew(list.parentID)) { + // list from a default_get, so fetch display_name for many2one fields + var many2ones = self._getMany2OneFieldNames(list); + var defs = _.map(many2ones, function (name) { + return self._fetchNameGets(list, name); + }); + return $.when.apply($, defs); + } + }); } else { def = this._searchReadUngroupedList(list); } @@ -2490,8 +2551,9 @@ var BasicModel = AbstractModel.extend({ _fetchX2Manys: function (record, options) { var self = this; var defs = []; - var fieldNames = options && options.fieldNames || record.getFieldNames(); - var viewType = options && options.viewType || record.viewType; + options = options || {}; + var fieldNames = options.fieldNames || record.getFieldNames(options); + var viewType = options.viewType || record.viewType; _.each(fieldNames, function (fieldName) { var field = record.fields[fieldName]; if (field.type === 'one2many' || field.type === 'many2many') { @@ -2516,6 +2578,11 @@ var BasicModel = AbstractModel.extend({ relationField: field.relation_field, viewType: view ? view.type : fieldInfo.viewType, }); + // set existing changes to the list + if (record._changes && record._changes[fieldName]) { + list._changes = self.localData[record._changes[fieldName]]._changes; + record._changes[fieldName] = list.id; + } record.data[fieldName] = list.id; if (!fieldInfo.__no_fetch) { var def = self._readUngroupedList(list).then(function () { @@ -3014,11 +3081,32 @@ var BasicModel = AbstractModel.extend({ * default view type. * * @param {Object} element an element from the localData + * @param {Object} [options] + * @param {Object} [options.viewType] current viewType. If not set, we will + * assume main viewType from the record * @returns {string[]} the list of field names */ - _getFieldNames: function (element) { + _getFieldNames: function (element, options) { var fieldsInfo = element.fieldsInfo; - return Object.keys(fieldsInfo && fieldsInfo[element.viewType] || {}); + var viewType = options && options.viewType || element.viewType; + return Object.keys(fieldsInfo && fieldsInfo[viewType] || {}); + }, + /** + * Get many2one fields names in a datapoint. This is useful in order to + * fetch their names in the case of a default_get. + * + * @private + * @param {Object} datapoint a valid resource object + * @returns {string[]} list of field names that are many2one + */ + _getMany2OneFieldNames: function (datapoint) { + var many2ones = []; + _.each(datapoint.fields, function (field, name) { + if (field.type === 'many2one') { + many2ones.push(name); + } + }); + return many2ones; }, /** * Evaluate the record evaluation context. This method is supposed to be @@ -3072,6 +3160,15 @@ var BasicModel = AbstractModel.extend({ // when sent to the server, the x2manys values must be a list // of commands in a context, but the list of ids in a domain ids.toJSON = _generateX2ManyCommands.bind(null, fieldName); + } else if (field.type === 'one2many') { // Ids are evaluated as a list of ids + /* Filtering out virtual ids from the ids list + * The server will crash if there are virtual ids in there + * The webClient doesn't do literal id list comparison like ids == list + * Only relevant in o2m: m2m does create actual records in db + */ + ids = _.filter(ids, function (id) { + return typeof id !== 'string'; + }); } context[fieldName] = ids; } @@ -3166,6 +3263,9 @@ var BasicModel = AbstractModel.extend({ * @param {Object} [options] * @param {string[]} [options.fieldNames] the fields to fetch for a record * @param {boolean} [options.onlyGroups=false] + * @param {boolean} [options.keepEmptyGroups=false] if set, the groups not + * present in the read_group anymore (empty groups) will stay in the + * datapoint (used to mimic the kanban renderer behaviour for example) * @returns {Deferred} */ _load: function (dataPoint, options) { @@ -3318,9 +3418,29 @@ var BasicModel = AbstractModel.extend({ */ _makeDefaultRecord: function (modelName, params) { var self = this; + + var determineExtraFields = function() { + // Fields that are present in the originating view, that need to be initialized + // Hence preventing their value to crash when getting back to the originating view + var parentRecord = self.localData[params.parentID]; + + var originView = parentRecord && parentRecord.fieldsInfo; + if (!originView || !originView[parentRecord.viewType]) + return []; + + var fieldsFromOrigin = _.filter(Object.keys(originView[parentRecord.viewType]), + function(fieldname) { + return params.fields[fieldname] !== undefined; + }); + + return fieldsFromOrigin; + } + var fieldNames = Object.keys(params.fieldsInfo[params.viewType]); var fields_key = _.without(fieldNames, '__last_update'); + var extraFields = determineExtraFields(); + return this._rpc({ model: modelName, method: 'default_get', @@ -3338,7 +3458,7 @@ var BasicModel = AbstractModel.extend({ viewType: params.viewType, }); - return self.applyDefaultValues(record.id, result) + return self.applyDefaultValues(record.id, result, {fieldNames: _.union(fieldNames, extraFields)}) .then(function () { var def = $.Deferred(); self._performOnChange(record, fields_key).always(function () { @@ -3475,13 +3595,16 @@ var BasicModel = AbstractModel.extend({ * @see _fetchRecord @see _makeDefaultRecord * * @param {Object} record - * @param {Object} record + * @param {Object} [options] + * @param {Object} [options.viewType] current viewType. If not set, we will + * assume main viewType from the record * @returns {Deferred} resolves to the finished resource */ _postprocess: function (record, options) { var self = this; var defs = []; - _.each(record.getFieldNames(), function (name) { + + _.each(record.getFieldNames(options), function (name) { var field = record.fields[name]; var fieldInfo = record.fieldsInfo[record.viewType][name] || {}; var options = fieldInfo.options || {}; @@ -3572,6 +3695,7 @@ var BasicModel = AbstractModel.extend({ parentID: x2manyList.id, viewType: viewType, }); + r._noAbandon = true; x2manyList._changes.push({operation: 'ADD', id: r.id}); x2manyList._cache[r.res_id] = r.id; @@ -3589,8 +3713,9 @@ var BasicModel = AbstractModel.extend({ if (isFieldInView) { var field = r.fields[fieldName]; var fieldType = field.type; + var rec; if (fieldType === 'many2one') { - var rec = self._makeDataPoint({ + rec = self._makeDataPoint({ context: r.context, modelName: field.relation, data: {id: r._changes[fieldName]}, @@ -3598,9 +3723,21 @@ var BasicModel = AbstractModel.extend({ }); r._changes[fieldName] = rec.id; many2ones[fieldName] = true; + } else if (fieldType === 'reference') { + var reference = r._changes[fieldName].split(','); + rec = self._makeDataPoint({ + context: r.context, + modelName: reference[0], + data: {id: parseInt(reference[1])}, + parentID: r.id, + }); + r._changes[fieldName] = rec.id; + many2ones[fieldName] = true; } else if (_.contains(['one2many', 'many2many'], fieldType)) { - var x2mCommands = commands[0][2][fieldName]; + var x2mCommands = value[2][fieldName]; defs.push(self._processX2ManyCommands(r, fieldName, x2mCommands)); + } else { + r._changes[fieldName] = self._parseServerValue(field, r._changes[fieldName]); } } } @@ -3795,12 +3932,28 @@ var BasicModel = AbstractModel.extend({ defs.push(self._load(newGroup, options)); } }); + if (options && options.keepEmptyGroups) { + // Find the groups that were available in a previous + // readGroup but are not there anymore. + // Note that these groups are put after existing groups so + // the order is not conserved. A sort *might* be useful. + var emptyGroupsIDs = _.difference(_.pluck(previousGroups, 'id'), list.data); + _.each(emptyGroupsIDs, function (groupID) { + list.data.push(groupID); + var emptyGroup = self.localData[groupID]; + // this attribute hasn't been updated in the previous + // loop for empty groups + emptyGroup.aggregateValues = {}; + }); + } return $.when.apply($, defs).then(function () { - // generate the res_ids of the main list, being the concatenation - // of the fetched res_ids in each group - list.res_ids = _.flatten(_.map(arguments, function (group) { - return group ? group.res_ids : []; - })); + if (!options || !options.onlyGroups) { + // generate the res_ids of the main list, being the concatenation + // of the fetched res_ids in each group + list.res_ids = _.flatten(_.map(arguments, function (group) { + return group ? group.res_ids : []; + })); + } return list; }); }); @@ -4030,10 +4183,20 @@ var BasicModel = AbstractModel.extend({ var r2 = self.localData[record2ID]; var data1 = _.extend({}, r1.data, r1._changes); var data2 = _.extend({}, r2.data, r2._changes); - if (data1[order.name] < data2[order.name]) { + + // Default value to sort against: the value of the field + var orderData1 = data1[order.name]; + var orderData2 = data2[order.name]; + + // If the field is a relation, sort on the display_name of those records + if (list.fields[order.name].type === 'many2one') { + orderData1 = orderData1 ? self.localData[orderData1].data.display_name : ""; + orderData2 = orderData2 ? self.localData[orderData2].data.display_name : ""; + } + if (orderData1 < orderData2) { return order.asc ? -1 : 1; } - if (data1[order.name] > data2[order.name]) { + if (orderData1 > orderData2) { return order.asc ? 1 : -1; } return compareRecords(record1ID, record2ID, level + 1); diff --git a/addons/web/static/src/js/views/basic/basic_renderer.js b/addons/web/static/src/js/views/basic/basic_renderer.js index eae7fa80..b58df7ce 100644 --- a/addons/web/static/src/js/views/basic/basic_renderer.js +++ b/addons/web/static/src/js/views/basic/basic_renderer.js @@ -704,11 +704,11 @@ var BasicRenderer = AbstractRenderer.extend({ /** * When someone presses the TAB/UP/DOWN/... key in a widget, it is nice to * be able to navigate in the view (default browser behaviors are disabled - * by Odoo). + * by Flectra). * * @abstract * @private - * @param {OdooEvent} ev + * @param {FlectraEvent} ev */ _onNavigationMove: function (ev) {}, }); diff --git a/addons/web/static/src/js/views/calendar/calendar_model.js b/addons/web/static/src/js/views/calendar/calendar_model.js index adbaa007..180d3030 100644 --- a/addons/web/static/src/js/views/calendar/calendar_model.js +++ b/addons/web/static/src/js/views/calendar/calendar_model.js @@ -253,7 +253,10 @@ return AbstractModel.extend({ * @param {Moment} start */ setDate: function (start) { - this.data.start_date = this.data.end_date = this.data.target_date = this.data.highlight_date = start; + // keep highlight/target_date in localtime + this.data.highlight_date = this.data.target_date = start.clone(); + // set dates in UTC with timezone applied manually + this.data.start_date = this.data.end_date = start; this.data.start_date.utc().add(this.getSession().getTZOffset(this.data.start_date), 'minutes'); switch (this.data.scale) { @@ -386,7 +389,7 @@ return AbstractModel.extend({ monthNamesShort: moment.monthsShort(), dayNames: moment.weekdays(), dayNamesShort: moment.weekdaysShort(), - firstDay: moment().startOf('week').isoWeekday(), + firstDay: moment()._locale._week.dow, }; }, /** @@ -539,6 +542,7 @@ return AbstractModel.extend({ }); var fs = []; + var undefined_fs = []; _.each(events, function (event) { var data = event.record[fieldName]; if (!_.contains(['many2many', 'one2many'], field.type)) { @@ -548,15 +552,18 @@ return AbstractModel.extend({ } _.each(data, function (_value) { var value = _.isArray(_value) ? _value[0] : _value; - fs.push({ + var f = { 'color_index': self.model_color === (field.relation || element.model) ? value : false, 'value': value, - 'label': fieldUtils.format[field.type](_value, field), + 'label': fieldUtils.format[field.type](_value, field) || _t("Undefined"), 'avatar_model': field.relation || element.model, - }); + }; + // if field used as color does not have value then push filter in undefined_fs, + // such filters should come last in filter list with Undefined string, later merge it with fs + value ? fs.push(f) : undefined_fs.push(f); }); }); - _.each(fs, function (f) { + _.each(_.union(fs, undefined_fs), function (f) { var f1 = _.findWhere(filter.filters, f); if (f1) { f1.display = true; @@ -611,7 +618,8 @@ return AbstractModel.extend({ var date_start; var date_stop; var date_delay = evt[this.mapping.date_delay] || 1.0, - all_day = this.mapping.all_day ? evt[this.mapping.all_day] : false, + all_day = this.fields[this.mapping.date_start].type === 'date' || + this.mapping.all_day && evt[this.mapping.all_day] || false, the_title = '', attendees = []; diff --git a/addons/web/static/src/js/views/calendar/calendar_view.js b/addons/web/static/src/js/views/calendar/calendar_view.js index a25bca29..efa90554 100644 --- a/addons/web/static/src/js/views/calendar/calendar_view.js +++ b/addons/web/static/src/js/views/calendar/calendar_view.js @@ -112,8 +112,20 @@ var CalendarView = AbstractView.extend({ //if quick_add = is NOT False and IS specified in view, we this one for widgets.QuickCreate' this.controllerParams.quickAddPop = (!('quick_add' in attrs) || utils.toBoolElse(attrs.quick_add+'', true)); this.controllerParams.disableQuickCreate = params.disable_quick_create || !this.controllerParams.quickAddPop; - // If this field is set ot true, we don't open the event in form view, but in a popup with the view_id passed by this parameter - this.controllerParams.formViewId = !attrs.form_view_id || !utils.toBoolElse(attrs.form_view_id, true) ? false : attrs.form_view_id; + + // If form_view_id is set, then the calendar view will open a form view + // with this id, when it needs to edit or create an event. + this.controllerParams.formViewId = + attrs.form_view_id ? parseInt(attrs.form_view_id, 10) : false; + if (!this.controllerParams.formViewId && params.action) { + var formViewDescr = _.find(params.action.views, function (v) { + return v[1] === 'form'; + }); + if (formViewDescr) { + this.controllerParams.formViewId = formViewDescr[0]; + } + } + this.controllerParams.readonlyFormViewId = !attrs.readonly_form_view_id || !utils.toBoolElse(attrs.readonly_form_view_id, true) ? false : attrs.readonly_form_view_id; this.controllerParams.eventOpenPopup = utils.toBoolElse(attrs.event_open_popup || '', false); this.controllerParams.mapping = mapping; diff --git a/addons/web/static/src/js/views/form/form_controller.js b/addons/web/static/src/js/views/form/form_controller.js index 1aa62e37..7f487a21 100644 --- a/addons/web/static/src/js/views/form/form_controller.js +++ b/addons/web/static/src/js/views/form/form_controller.js @@ -14,7 +14,7 @@ var FormController = BasicController.extend({ custom_events: _.extend({}, BasicController.prototype.custom_events, { bounce_edit: '_onBounceEdit', button_clicked: '_onButtonClicked', - freeze_order: '_onFreezeOrder', + edited_list: '_onEditedList', open_one2many_record: '_onOpenOne2ManyRecord', open_record: '_onOpenRecord', toggle_column_order: '_onToggleColumnOrder', @@ -100,6 +100,7 @@ var FormController = BasicController.extend({ * @todo convert to new style */ on_attach_callback: function () { + this._super.apply(this, arguments); this.autofocus(); }, /** @@ -213,6 +214,16 @@ var FormController = BasicController.extend({ return changedFields; }); }, + /** + * Overrides to force the viewType to 'form', so that we ensure that the + * correct fields are reloaded (this is only useful for one2many form views). + * + * @override + */ + update: function (params, options) { + params = _.extend({viewType: 'form'}, params); + return this._super(params, options); + }, //-------------------------------------------------------------------------- // Private @@ -467,11 +478,15 @@ var FormController = BasicController.extend({ * in a x2many list view * * @private - * @param {FlectraEvent} event + * @param {FlectraEvent} ev + * @param {integer} ev.id of the list to freeze while editing a line */ - _onFreezeOrder: function (event) { - event.stopPropagation(); - this.model.freezeOrder(event.data.id); + _onEditedList: function (ev) { + ev.stopPropagation(); + if (ev.data.id) { + this.model.save(ev.data.id, {savePoint: true}); + } + this.model.freezeOrder(ev.data.id); }, /** * Opens a one2many record (potentially new) in a dialog. This handler is diff --git a/addons/web/static/src/js/views/graph/graph_renderer.js b/addons/web/static/src/js/views/graph/graph_renderer.js index 29735f0f..95230b08 100644 --- a/addons/web/static/src/js/views/graph/graph_renderer.js +++ b/addons/web/static/src/js/views/graph/graph_renderer.js @@ -43,6 +43,30 @@ return AbstractRenderer.extend({ nv.utils.offWindowResize(this.to_remove); this._super(); }, + /** + * The graph view uses the nv(d3) lib to render the graph. This lib requires + * that the rendering is done directly into the DOM (so that it can correctly + * compute positions). However, the views are always rendered in fragments, + * and appended to the DOM once ready (to prevent them from flickering). We + * here use the on_attach_callback hook, called when the widget is attached + * to the DOM, to perform the rendering. This ensures that the rendering is + * always done in the DOM. + * + * @override + */ + on_attach_callback: function () { + this._super.apply(this, arguments); + this.isInDOM = true; + this._renderGraph(); + }, + /** + * @override + */ + on_detach_callback: function () { + this._super.apply(this, arguments); + this.isInDOM = false; + }, + //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- @@ -51,10 +75,9 @@ return AbstractRenderer.extend({ * Render the chart. * * Note that This method is synchronous, but the actual rendering is done - * asynchronously (in a setTimeout). The reason for that is that nvd3/d3 - * needs to be in the DOM to correctly render itself. So, we trick Flectra by - * returning immediately, then wait a tiny interval before actually - * displaying the data. + * asynchronously. The reason for that is that nvd3/d3 needs to be in the + * DOM to correctly render itself. So, we trick Flectra by returning + * immediately, then we render the chart when the widget is in the DOM. * * @returns {Deferred} The _super deferred is actually resolved immediately */ @@ -76,21 +99,15 @@ return AbstractRenderer.extend({ "Try to add some records, or make sure that " + "there is no active filter in the search bar."), })); - } else { - var self = this; - setTimeout(function () { - self.$el.empty(); - var chart = self['_render' + _.str.capitalize(self.state.mode) + 'Chart'](); - if (chart && chart.tooltip.chartContainer) { - self.to_remove = chart.update; - nv.utils.onWindowResize(chart.update); - chart.tooltip.chartContainer(self.el); - } - }, 0); + } else if (this.isInDOM) { + // only render the graph if the widget is already in the DOM (this + // happens typically after an update), otherwise, it will be + // rendered when the widget will be attached to the DOM (see + // 'on_attach_callback') + this._renderGraph(); } return this._super.apply(this, arguments); }, - /** * Helper function to set up data properly for the multiBarChart model in * nvd3. @@ -327,6 +344,21 @@ return AbstractRenderer.extend({ chart(svg); return chart; }, + /** + * Renders the graph according to its type. This function must be called + * when the renderer is in the DOM (for nvd3 to render the graph correctly). + * + * @private + */ + _renderGraph: function () { + this.$el.empty(); + var chart = this['_render' + _.str.capitalize(this.state.mode) + 'Chart'](); + if (chart && chart.tooltip.chartContainer) { + this.to_remove = chart.update; + nv.utils.onWindowResize(chart.update); + chart.tooltip.chartContainer(this.el); + } + }, }); }); diff --git a/addons/web/static/src/js/views/kanban/kanban_controller.js b/addons/web/static/src/js/views/kanban/kanban_controller.js index a86ebfcb..c40a7f0b 100644 --- a/addons/web/static/src/js/views/kanban/kanban_controller.js +++ b/addons/web/static/src/js/views/kanban/kanban_controller.js @@ -10,6 +10,7 @@ flectra.define('web.KanbanController', function (require) { var BasicController = require('web.BasicController'); var Context = require('web.Context'); var core = require('web.core'); +var Domain = require('web.Domain'); var view_dialogs = require('web.view_dialogs'); var _t = core._t; @@ -106,6 +107,16 @@ var KanbanController = BasicController.extend({ var groupedByM2o = groupByField && (groupByField.type === 'many2one'); return groupedByM2o; }, + /** + * @param {number[]} ids + * @private + * @returns {Deferred} + */ + _resequenceColumns: function (ids) { + var state = this.model.get(this.handle, {raw: true}); + var model = state.fields[state.groupedBy[0]].relation; + return this.model.resequence(model, ids, this.handle); + }, /** * This method calls the server to ask for a resequence. Note that this * does not rerender the user interface, because in most case, the @@ -155,6 +166,10 @@ var KanbanController = BasicController.extend({ _onAddColumn: function (event) { var self = this; this.model.createGroup(event.data.value, this.handle).then(function () { + var state = self.model.get(self.handle, {raw: true}); + var ids = _.pluck(state.data, 'res_id').filter(_.isNumber); + return self._resequenceColumns(ids); + }).then(function () { return self.update({}, {reload: false}); }).then(function () { self._updateButtons(); @@ -228,10 +243,57 @@ var KanbanController = BasicController.extend({ resIDs: record.res_ids, }, on_closed: function () { + var recordModel = self.model.localData[record.id]; + var group = self.model.localData[recordModel.parentID]; + var parent = self.model.localData[group.parentID]; + self.model.reload(record.id).then(function (db_id) { var data = self.model.get(db_id); var kanban_record = event.target; kanban_record.update(data); + + // Check if we still need to display the record. Some fields of the domain are + // not guaranteed to be in data. This is for example the case if the action + // contains a domain on a field which is not in the Kanban view. Therefore, + // we need to handle multiple cases based on 3 variables: + // domInData: all domain fields are in the data + // activeInDomain: 'active' is already in the domain + // activeInData: 'active' is available in the data + + var domain = (parent ? parent.domain : group.domain) || []; + var domInData = _.every(domain, function (d) { + return d[0] in data.data; + }); + var activeInDomain = _.pluck(domain, 0).indexOf('active') !== -1; + var activeInData = 'active' in data.data; + + // Case # | domInData | activeInDomain | activeInData + // 1 | true | true | true => no domain change + // 2 | true | true | false => not possible + // 3 | true | false | true => add active in domain + // 4 | true | false | false => no domain change + // 5 | false | true | true => no evaluation + // 6 | false | true | false => no evaluation + // 7 | false | false | true => replace domain + // 8 | false | false | false => no evaluation + + // There are 3 cases which cannot be evaluated since we don't have all the + // necessary information. The complete solution would be to perform a RPC in + // these cases, but this is out of scope. A simpler one is to do a try / catch. + + if (domInData && !activeInDomain && activeInData) { + domain = domain.concat([['active', '=', true]]); + } else if (!domInData && !activeInDomain && activeInData) { + domain = [['active', '=', true]]; + } + try { + var visible = new Domain(domain).compute(data.evalContext); + } catch (e) { + return; + } + if (!visible) { + kanban_record.destroy(); + } }); }, }); @@ -380,9 +442,7 @@ var KanbanController = BasicController.extend({ */ _onResequenceColumn: function (event) { var self = this; - var state = this.model.get(this.handle, {raw: true}); - var model = state.fields[state.groupedBy[0]].relation; - this.model.resequence(model, event.data.ids, this.handle).then(function () { + this._resequenceColumns(event.data.ids).then(function () { self._updateEnv(); }); }, diff --git a/addons/web/static/src/js/views/kanban/kanban_model.js b/addons/web/static/src/js/views/kanban/kanban_model.js index 22c9751f..4f822cb8 100644 --- a/addons/web/static/src/js/views/kanban/kanban_model.js +++ b/addons/web/static/src/js/views/kanban/kanban_model.js @@ -348,9 +348,8 @@ var KanbanModel = BasicModel.extend({ return $.when(); }, /** - * Reloads all progressbar data if the given id is a record's one. This is - * done after given deferred and insures that the given deferred's result is - * not lost. + * Reloads all progressbar data. This is done after given deferred and + * insures that the given deferred's result is not lost. * * @private * @param {string} recordID @@ -359,7 +358,9 @@ var KanbanModel = BasicModel.extend({ */ _reloadProgressBarGroupFromRecord: function (recordID, def) { var element = this.localData[recordID]; - if (element.type !== 'record') { + if (element.type === 'list' && !element.parentID) { + // we are reloading the whole view, so there is no need to manually + // reload the progressbars return def; } @@ -369,7 +370,10 @@ var KanbanModel = BasicModel.extend({ while (element) { if (element.progressBar) { return def.then(function (data) { - return self._load(element, {onlyGroups: true}).then(function () { + return self._load(element, { + keepEmptyGroups: true, + onlyGroups: true, + }).then(function () { return data; }); }); diff --git a/addons/web/static/src/js/views/list/list_editable_renderer.js b/addons/web/static/src/js/views/list/list_editable_renderer.js index 339d695e..7288ef48 100644 --- a/addons/web/static/src/js/views/list/list_editable_renderer.js +++ b/addons/web/static/src/js/views/list/list_editable_renderer.js @@ -531,8 +531,13 @@ ListRenderer.include({ * @returns {Deferred} this deferred is resolved immediately */ _renderView: function () { + var self = this; this.currentRow = null; - return this._super.apply(this, arguments); + return this._super.apply(this, arguments).then(function () { + if (self._isEditable()) { + self.$('table').addClass('o_editable_list'); + } + }); }, /** * Force the resequencing of the items in the list. diff --git a/addons/web/static/src/js/views/pivot/pivot_model.js b/addons/web/static/src/js/views/pivot/pivot_model.js index 393e7542..ce79b955 100644 --- a/addons/web/static/src/js/views/pivot/pivot_model.js +++ b/addons/web/static/src/js/views/pivot/pivot_model.js @@ -17,6 +17,7 @@ flectra.define('web.PivotModel', function (require) { */ var AbstractModel = require('web.AbstractModel'); +var concurrency = require('web.concurrency'); var core = require('web.core'); var session = require('web.session'); var utils = require('web.utils'); @@ -32,6 +33,7 @@ var PivotModel = AbstractModel.extend({ this._super.apply(this, arguments); this.numbering = {}; this.data = null; + this._loadDataDropPrevious = new concurrency.DropPrevious(); }, //-------------------------------------------------------------------------- @@ -250,6 +252,7 @@ var PivotModel = AbstractModel.extend({ this.data.colGroupBys = params.context.pivot_column_groupby || this.data.colGroupBys; this.data.groupedBy = params.context.pivot_row_groupby || this.data.groupedBy; this.data.measures = this._processMeasures(params.context.pivot_measures) || this.data.measures; + this.defaultGroupedBy = this.data.groupedBy.length ? this.data.groupedBy : this.defaultGroupedBy; } if ('domain' in params) { this.data.domain = params.domain; @@ -278,7 +281,7 @@ var PivotModel = AbstractModel.extend({ self._updateTree(old_col_root, self.data.main_col.root); new_groupby_length = self._getHeaderDepth(self.data.main_col.root) - 1; - self.data.main_row.groupbys = old_col_root.groupbys.slice(0, new_groupby_length); + self.data.main_row.groupbys = old_row_root.groupbys.slice(0, new_groupby_length); }); }, /** @@ -531,7 +534,7 @@ var PivotModel = AbstractModel.extend({ } } - return $.when.apply(null, groupBys.map(function (groupBy) { + return this._loadDataDropPrevious.add($.when.apply(null, groupBys.map(function (groupBy) { return self._rpc({ model: self.modelName, method: 'read_group', @@ -541,7 +544,7 @@ var PivotModel = AbstractModel.extend({ groupBy: groupBy, lazy: false, }); - })).then(function () { + }))).then(function () { var data = Array.prototype.slice.call(arguments); if (data[0][0].__count === 0) { self.data.has_data = false; diff --git a/addons/web/static/src/js/views/view_dialogs.js b/addons/web/static/src/js/views/view_dialogs.js index 97d37154..77d9b143 100644 --- a/addons/web/static/src/js/views/view_dialogs.js +++ b/addons/web/static/src/js/views/view_dialogs.js @@ -430,7 +430,7 @@ var SelectCreateDialog = ViewDialog.extend({ * list controller. * * @private - * @param {OdooEvent} ev + * @param {FlectraEvent} ev * @param {function} ev.data.callback used to send the requested context */ _onGetControllerContext: function (ev) { diff --git a/addons/web/static/src/js/widgets/date_picker.js b/addons/web/static/src/js/widgets/date_picker.js index c6907357..8c09b3dc 100644 --- a/addons/web/static/src/js/widgets/date_picker.js +++ b/addons/web/static/src/js/widgets/date_picker.js @@ -79,8 +79,15 @@ var DateWidget = Widget.extend({ var oldValue = this.getValue(); this._setValueFromUi(); var newValue = this.getValue(); - - if (!oldValue !== !newValue || oldValue && newValue && !oldValue.isSame(newValue)) { + var hasChanged = !oldValue !== !newValue; + if (oldValue && newValue) { + var formattedOldValue = oldValue.format(time.getLangDatetimeFormat()); + var formattedNewValue = newValue.format(time.getLangDatetimeFormat()) + if (formattedNewValue !== formattedOldValue) { + hasChanged = true; + } + } + if (hasChanged) { // The condition is strangely written; this is because the // values can be false/undefined this.trigger("datetime_changed"); diff --git a/addons/web/static/src/js/widgets/debug_manager.js b/addons/web/static/src/js/widgets/debug_manager.js index e5660997..6e3d10ee 100644 --- a/addons/web/static/src/js/widgets/debug_manager.js +++ b/addons/web/static/src/js/widgets/debug_manager.js @@ -178,6 +178,7 @@ var DebugManager = Widget.extend({ }) .then(function (views) { var view = views[0]; + view.type = view.type === 'tree' ? 'list' : view.type; // ignore tree view self.do_action({ type: 'ir.actions.act_window', name: view.name, diff --git a/addons/web/static/src/less/bootstrap_overridden.less b/addons/web/static/src/less/bootstrap_overridden.less index e07f767b..d4a8197e 100644 --- a/addons/web/static/src/less/bootstrap_overridden.less +++ b/addons/web/static/src/less/bootstrap_overridden.less @@ -176,6 +176,8 @@ } } +@icon-font-path: "/web/static/lib/bootstrap/fonts/"; + @btn-default-color: #fff; @btn-default-bg: @gray-light; @btn-default-border: transparent; diff --git a/addons/web/static/src/less/datepicker.less b/addons/web/static/src/less/datepicker.less index 03211ea5..83535c3c 100644 --- a/addons/web/static/src/less/datepicker.less +++ b/addons/web/static/src/less/datepicker.less @@ -17,11 +17,12 @@ .o_datepicker_input { width: 100%; + cursor: pointer; } .o_datepicker_button { .o-position-absolute(2px, 4px); - cursor: pointer; + pointer-events: none; // let click events go through the underlying input &:after { .o-caret-down; } diff --git a/addons/web/static/src/less/fields.less b/addons/web/static/src/less/fields.less index cb27a204..8c815ab2 100644 --- a/addons/web/static/src/less/fields.less +++ b/addons/web/static/src/less/fields.less @@ -39,9 +39,7 @@ // Flex fields (inline) &.o_field_many2one, &.o_field_radio, &.o_field_many2manytags, &.o_field_percent_pie, &.o_field_monetary, &.o_field_binary_file { - @media (min-width: @screen-sm-min) { - .o-inline-flex-display(); - } + .o-inline-flex-display(); > span, > button { .o-flex(0, 0, auto); } @@ -135,7 +133,7 @@ display: inline-block; padding: 0; margin: 0; - vertical-align: middle; + vertical-align: baseline; > .o_priority_star { display: inline-block; font-size: 1.35em; @@ -185,6 +183,9 @@ // Radio buttons &.o_field_radio { + @media (max-width: @screen-xs-max) { + display: inline-block; + } .o_radio_input { outline: none; } @@ -309,6 +310,10 @@ &:hover .o_form_image_controls { opacity: 0.8; } + + &.o_field_invalid > img { + border: 1px solid @brand-danger; + } } // Input loading/file diff --git a/addons/web/static/src/less/form_view.less b/addons/web/static/src/less/form_view.less index 7b8a47ff..c34b6b9f 100644 --- a/addons/web/static/src/less/form_view.less +++ b/addons/web/static/src/less/form_view.less @@ -248,6 +248,10 @@ border-left-color: @gray-lighter; } } + + .caret { + margin-top: -2px; + } } .o-status-more { diff --git a/addons/web/static/src/less/kanban_column_progressbar.less b/addons/web/static/src/less/kanban_column_progressbar.less index 41f12bf1..a68e8bcc 100644 --- a/addons/web/static/src/less/kanban_column_progressbar.less +++ b/addons/web/static/src/less/kanban_column_progressbar.less @@ -91,6 +91,7 @@ margin-left: 3%; color: @headings-color; text-align: right; + white-space: nowrap; .o-transform-origin(right, center); &.o_kanban_grow { diff --git a/addons/web/static/src/less/kanban_dashboard.less b/addons/web/static/src/less/kanban_dashboard.less index df73c2a6..acf76fbc 100644 --- a/addons/web/static/src/less/kanban_dashboard.less +++ b/addons/web/static/src/less/kanban_dashboard.less @@ -223,6 +223,7 @@ } .o_favorite { + top: 3px; left: 0; right: auto; } diff --git a/addons/web/static/src/less/list_view.less b/addons/web/static/src/less/list_view.less index 8f3b0d56..b35e916b 100644 --- a/addons/web/static/src/less/list_view.less +++ b/addons/web/static/src/less/list_view.less @@ -6,6 +6,14 @@ background-color: @flectra-view-background-color; margin-bottom: 0px; + // Checkbox in editable list + // should be clickable and activate the row + &.o_editable_list .o_data_row:not(.o_selected_row) .o_data_cell { + .o_checkbox:not(.o_readonly_modifier) { + pointer-events: none; + } + } + &.table { td, th { vertical-align: middle; @@ -37,12 +45,14 @@ .user-select(none); // Prevent unwanted selection while sorting &::after { - margin-left: 6px; - position: absolute; font-family: FontAwesome; content: "\f0d7"; opacity: 0; } + &:not(:empty)::after { + margin-left: 6px; + position: absolute; + } &.o-sort-up { cursor: n-resize; &::after { @@ -74,7 +84,6 @@ padding: 0px; background-style: none; border-style: none; - height: 0px; display: table-cell; } diff --git a/addons/web/static/src/less/pivot_view.less b/addons/web/static/src/less/pivot_view.less index 0d70d26f..d1c684e1 100644 --- a/addons/web/static/src/less/pivot_view.less +++ b/addons/web/static/src/less/pivot_view.less @@ -76,4 +76,4 @@ // ------------------------------------------------------------------ .o_pivot_measures_list { .o-selected-li; -} \ No newline at end of file +} \ No newline at end of file diff --git a/addons/web/static/src/less/web_calendar.less b/addons/web/static/src/less/web_calendar.less index d6e1f1b9..16f7ee82 100644 --- a/addons/web/static/src/less/web_calendar.less +++ b/addons/web/static/src/less/web_calendar.less @@ -71,11 +71,6 @@ } } } - - .o_target_date:not(.fc-today) { - background-color: @flectra-brand-primary; - opacity: 0.1; - } } .o_calendar_sidebar_container { diff --git a/addons/web/static/tests/boot_tests.js b/addons/web/static/tests/boot_tests.js index 83d7c549..af320359 100644 --- a/addons/web/static/tests/boot_tests.js +++ b/addons/web/static/tests/boot_tests.js @@ -18,4 +18,4 @@ flectra.__DEBUG__.didLogInfo.then(function() { }); -})(); \ No newline at end of file +})(); \ No newline at end of file diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js index 1ddde029..78aafe52 100644 --- a/addons/web/static/tests/fields/basic_fields_tests.js +++ b/addons/web/static/tests/fields/basic_fields_tests.js @@ -1698,7 +1698,6 @@ QUnit.module('basic_fields', { assert.strictEqual(form.$('div[name="document"] > img').attr('width'), "90", "the image should correctly set its attributes"); form.destroy(); - }); QUnit.test('image fields in subviews are loaded correctly', function (assert) { @@ -1750,6 +1749,34 @@ QUnit.module('basic_fields', { form.destroy(); }); + QUnit.test('image fields with required attribute', function (assert) { + assert.expect(2); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '
' + + '' + + '', + mockRPC: function (route, args) { + if (args.method === 'create') { + throw new Error("Should not do a create RPC with unset required image field"); + } + return this._super.apply(this, arguments); + }, + }); + + form.$buttons.find('.o_form_button_save').click(); + + assert.ok(form.$('.o_form_view').hasClass('o_form_editable'), + "form view should still be editable"); + assert.ok(form.$('.o_field_widget').hasClass('o_field_invalid'), + "image field should be displayed as invalid"); + + form.destroy(); + }); + QUnit.module('JournalDashboardGraph', { beforeEach: function () { _.extend(this.data.partner.fields, { @@ -2262,6 +2289,38 @@ QUnit.module('basic_fields', { form.destroy(); }); + QUnit.test('datetime field in form view', function (assert) { + assert.expect(1); + + this.data.partner.fields.datetime.default = "2017-08-02 12:00:05"; + this.data.partner.fields.datetime.required = true; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'
', + res_id: 1, + translateParameters: { // Avoid issues due to localization formats + date_format: '%m/%d/%Y', + time_format: '%H:%M', + }, + }); + testUtils.patch(basicFields.FieldDate, { + _setValue: function () { + throw "The time format of the language must be taken into account." + return this._super.apply(this, arguments); + }, + }); + form.$buttons.find('.o_form_button_create').click(); + var expectedDateString = "08/02/2017 12:00"; // 10:00:00 without timezone + assert.strictEqual(form.$('.o_field_date input').val(), expectedDateString, + 'the datetime should be correctly displayed in readonly'); + + testUtils.unpatch(basicFields.FieldDate); + form.destroy(); + }); + QUnit.test('datetime field in editable list view', function (assert) { assert.expect(8); @@ -3010,7 +3069,7 @@ QUnit.module('basic_fields', { QUnit.module('PhoneWidget'); QUnit.test('phone field in form view on extra small screens', function (assert) { - assert.expect(7); + assert.expect(8); var form = createView({ View: FormView, @@ -3058,6 +3117,14 @@ QUnit.module('basic_fields', { assert.strictEqual($phoneLink.attr('href'), 'tel:new', "should still have proper tel prefix"); + // save phone with ­ and verify it is removed + form.$buttons.find('.o_form_button_edit').click(); + form.$('input[type="text"].o_field_widget').val('h\u00ADi').trigger('input'); + form.$buttons.find('.o_form_button_save').click(); + $phoneLink = form.$('a.o_form_uri.o_field_widget'); + assert.strictEqual($phoneLink.attr('href'), 'tel:hi', + "U+00AD should have been removed"); + form.destroy(); }); diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js index 2744c54d..6dca123a 100644 --- a/addons/web/static/tests/fields/relational_fields_tests.js +++ b/addons/web/static/tests/fields/relational_fields_tests.js @@ -143,10 +143,12 @@ QUnit.module('relational_fields', { user: { fields: { name: {string: "Name", type: "char"}, + partner_ids: {string: "one2many partners field", type: "one2many", relation: 'partner', relation_field: 'user_id'}, }, records: [{ id: 17, name: "Aline", + partner_ids: [1, 2], }, { id: 19, name: "Christine", @@ -599,6 +601,35 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('many2one in non edit mode', function (assert) { + assert.expect(3); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '
' + + '' + + '', + res_id: 1, + }); + + assert.strictEqual(form.$('a.o_form_uri').length, 1, + "should display 1 m2o link in form"); + assert.strictEqual(form.$('a.o_form_uri').attr('href'), "#id=4&model=partner", + "href should contain id and model"); + + // Remove value from many2one and then save, there should not have href with id and model on m2o anchor + form.$buttons.find('.o_form_button_edit').click(); + form.$('.o_field_many2one input').val('').trigger('keyup').trigger('focusout'); + form.$buttons.find('.o_form_button_save').click(); + + assert.strictEqual(form.$('a.o_form_uri').attr('href'), "#", + "href should have #"); + + form.destroy(); + }); + QUnit.test('many2one searches with correct value', function (assert) { var done = assert.async(); assert.expect(6); @@ -1150,12 +1181,12 @@ QUnit.module('relational_fields', { form.destroy(); }); - QUnit.test('list in form: discard newly added element with empty required field', function (assert) { + QUnit.test('item not dropped on discard with empty required field (default_get)', function (assert) { // This test simulates discarding a record that has been created with // one of its required field that is empty. When we discard the changes // on this empty field, it should not assume that this record should be // abandonned, since it has been added (even though it is a new record). - assert.expect(6); + assert.expect(8); var form = createView({ View: FormView, @@ -1173,7 +1204,7 @@ QUnit.module('relational_fields', { '', mockRPC: function (route, args) { if (args.method === 'default_get') { - return $.when({p: [[0, 0, {display_name: 'new record', trululu: false}]]}); + return $.when({ p: [[0, 0, { display_name: 'new record', trululu: false }]] }); } return this._super.apply(this, arguments); }, @@ -1181,6 +1212,8 @@ QUnit.module('relational_fields', { assert.strictEqual($('tr.o_data_row').length, 1, "should have created the new record in the o2m"); + assert.strictEqual($('td.o_data_cell').first().text(), "new record", + "should have the correct displayed name"); var requiredElement = $('td.o_data_cell.o_required_modifier'); assert.strictEqual(requiredElement.length, 1, @@ -1194,6 +1227,8 @@ QUnit.module('relational_fields', { assert.strictEqual($('tr.o_data_row').length, 1, "should still have the record in the o2m"); + assert.strictEqual($('td.o_data_cell').first().text(), "new record", + "should still have the correct displayed name"); // update selector of required field element requiredElement = $('td.o_data_cell.o_required_modifier'); @@ -1204,6 +1239,345 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('list in form: name_get with unique ids (default_get)', function (assert) { + assert.expect(2); + + this.data.partner.records[0].display_name = "MyTrululu"; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + mockRPC: function (route, args) { + if (args.method === 'default_get') { + return $.when({ + p: [ + [0, 0, { trululu: 1 }], + [0, 0, { trululu: 1 }] + ] + }); + } + if (args.method === 'name_get') { + assert.deepEqual(args.args[0], _.uniq(args.args[0]), + "should not have duplicates in name_get rpc"); + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(form.$('td.o_data_cell').text(), "MyTrululuMyTrululu", + "both records should have the correct display_name for trululu field"); + + form.destroy(); + }); + + QUnit.test('list in form: show name of many2one fields in multi-page (default_get)', function (assert) { + assert.expect(4); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + mockRPC: function (route, args) { + if (args.method === 'default_get') { + return $.when({ + p: [ + [0, 0, { display_name: 'record1', trululu: 1 }], + [0, 0, { display_name: 'record2', trululu: 2 }] + ] + }); + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(form.$('td.o_data_cell').first().text(), + "record1", "should show display_name of 1st record"); + assert.strictEqual(form.$('td.o_data_cell').first().next().text(), + "first record", "should show display_name of trululu of 1st record"); + + form.$('button.o_pager_next').click(); + + assert.strictEqual(form.$('td.o_data_cell').first().text(), + "record2", "should show display_name of 2nd record"); + assert.strictEqual(form.$('td.o_data_cell').first().next().text(), + "second record", "should show display_name of trululu of 2nd record"); + + form.destroy(); + }); + + QUnit.test('list in form: item not dropped on discard with empty required field (onchange in default_get)', function (assert) { + // variant of the test "list in form: discard newly added element with + // empty required field (default_get)", in which the `default_get` + // performs an `onchange` at the same time. This `onchange` may create + // some records, which should not be abandoned on discard, similarly + // to records created directly by `default_get` + assert.expect(7); + + var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY; + relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0; + + this.data.partner.onchanges = { + product_id: function (obj) { + if (obj.product_id === 37) { + obj.p = [[0, 0, { display_name: "entry", trululu: false }]]; + } + }, + }; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '', + mockRPC: function (route, args) { + if (args.method === 'default_get') { + return $.when({ + product_id: 37, + }); + } + return this._super.apply(this, arguments); + }, + }); + + // check that there is a record in the editable list with empty string as required field + assert.strictEqual(form.$('.o_data_row').length, 1, + "should have a row in the editable list"); + assert.strictEqual($('td.o_data_cell').first().text(), "entry", + "should have the correct displayed name"); + var requiredField = $('td.o_data_cell.o_required_modifier'); + assert.strictEqual(requiredField.length, 1, + "should have a required field on this record"); + assert.strictEqual(requiredField.text(), "", + "should have empty string in the required field on this record"); + + // click on empty required field in editable list record + requiredField.click(); + // click off so that the required field still stay empty + $('body').click(); + + // record should not be dropped + assert.strictEqual(form.$('.o_data_row').length, 1, + "should not have dropped record in the editable list"); + assert.strictEqual($('td.o_data_cell').first().text(), "entry", + "should still have the correct displayed name"); + assert.strictEqual($('td.o_data_cell.o_required_modifier').text(), "", + "should still have empty string in the required field"); + + relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY; + form.destroy(); + }); + + QUnit.test('list in form: item not dropped on discard with empty required field (onchange on list after default_get)', function (assert) { + // discarding a record from an `onchange` in a `default_get` should not + // abandon the record. This should not be the case for following + // `onchange`, except if an onchange make some changes on the list: + // in particular, if an onchange make changes on the list such that + // a record is added, this record should not be dropped on discard + assert.expect(8); + + var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY; + relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0; + + this.data.partner.onchanges = { + product_id: function (obj) { + if (obj.product_id === 37) { + obj.p = [[0, 0, { display_name: "entry", trululu: false }]]; + } + }, + }; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '', + }); + + // check no record in list + assert.strictEqual(form.$('.o_data_row').length, 0, + "should have no row in the editable list"); + + // select product_id to force on_change in editable list + form.$('.o_field_widget[name="product_id"] .o_input').click(); + $('.ui-menu-item').first().click(); + + // check that there is a record in the editable list with empty string as required field + assert.strictEqual(form.$('.o_data_row').length, 1, + "should have a row in the editable list"); + assert.strictEqual($('td.o_data_cell').first().text(), "entry", + "should have the correct displayed name"); + var requiredField = $('td.o_data_cell.o_required_modifier'); + assert.strictEqual(requiredField.length, 1, + "should have a required field on this record"); + assert.strictEqual(requiredField.text(), "", + "should have empty string in the required field on this record"); + + // click on empty required field in editable list record + requiredField.click(); + // click off so that the required field still stay empty + $('body').click(); + + // record should not be dropped + assert.strictEqual(form.$('.o_data_row').length, 1, + "should not have dropped record in the editable list"); + assert.strictEqual($('td.o_data_cell').first().text(), "entry", + "should still have the correct displayed name"); + assert.strictEqual($('td.o_data_cell.o_required_modifier').text(), "", + "should still have empty string in the required field"); + + relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY; + form.destroy(); + }); + + QUnit.test('item dropped on discard with empty required field with "Add an item" (invalid on "ADD")', function (assert) { + // when a record in a list is added with "Add an item", it should + // always be dropped on discard if some required field are empty + // at the record creation. + assert.expect(6); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + }); + + // Click on "Add an item" + form.$('.o_field_x2many_list_row_add a').click(); + var charField = form.$('.o_field_widget.o_field_char[name="display_name"]'); + var requiredField = form.$('.o_field_widget.o_required_modifier[name="trululu"]'); + charField.val("some text"); + assert.strictEqual(charField.length, 1, + "should have a char field 'display_name' on this record"); + assert.notOk(charField.hasClass('o_required_modifier'), + "the char field should not be required on this record"); + assert.strictEqual(charField.val(), "some text", + "should have entered text in the char field on this record"); + assert.strictEqual(requiredField.length, 1, + "should have a required field 'trululu' on this record"); + assert.strictEqual(requiredField.val().trim(), "", + "should have empty string in the required field on this record"); + + // click on empty required field in editable list record + requiredField.click(); + // click off so that the required field still stay empty + $('body').click(); + + // record should be dropped + assert.strictEqual(form.$('.o_data_row').length, 0, + "should have dropped record in the editable list"); + + form.destroy(); + }); + + QUnit.test('item not dropped on discard with empty required field with "Add an item" (invalid on "UPDATE")', function (assert) { + // when a record in a list is added with "Add an item", it should + // be temporarily added to the list when it is valid (e.g. required + // fields are non-empty). If the record is updated so that the required + // field is empty, and it is discarded, then the record should not be + // dropped. + assert.expect(8); + + var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY; + relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + }); + + assert.strictEqual(form.$('.o_data_row').length, 0, + "should initially not have any record in the list"); + + // Click on "Add an item" + form.$('.o_field_x2many_list_row_add a').click(); + assert.strictEqual(form.$('.o_data_row').length, 1, + "should have a temporary record in the list"); + + var $inputEditMode = form.$('.o_field_widget.o_required_modifier[name="trululu"] input'); + assert.strictEqual($inputEditMode.length, 1, + "should have a required field 'trululu' on this record"); + assert.strictEqual($inputEditMode.val(), "", + "should have empty string in the required field on this record"); + + // add something to required field and leave edit mode of the record + $inputEditMode.click(); + $('li.ui-menu-item').first().click(); + $('body').click(); // leave edit mode on the line + + var $inputReadonlyMode = form.$('.o_data_cell.o_required_modifier'); + assert.strictEqual(form.$('.o_data_row').length, 1, + "should not have dropped valid record when leaving edit mode"); + assert.strictEqual($inputReadonlyMode.text(), "first record", + "should have put some content in the required field on this record"); + + // remove the required field and leave edit mode of the record + $('.o_data_row').click(); // enter edit mode on the line + $inputEditMode.click(); + $inputEditMode.val(""); + $('body').click(); + + assert.strictEqual(form.$('.o_data_row').length, 1, + "should not have dropped record in the list on discard (invalid on UPDATE)"); + assert.strictEqual($inputReadonlyMode.text(), "first record", + "should keep previous valid required field content on this record"); + + relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY; + form.destroy(); + }); + QUnit.test('list in form: default_get with x2many create', function (assert) { assert.expect(5); @@ -2172,6 +2546,43 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('x2many list sorted by many2one', function (assert) { + assert.expect(3); + + this.data.partner.records[0].p = [1, 2, 4]; + this.data.partner.fields.trululu.sortable = true; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + res_id: 1, + }); + + assert.strictEqual(form.$('.o_data_row .o_list_number').text(), '124', + "should have correct order initially"); + + form.$('.o_list_view thead th:nth(1)').click(); + + assert.strictEqual(form.$('.o_data_row .o_list_number').text(), '412', + "should have correct order (ASC)"); + + form.$('.o_list_view thead th:nth(1)').click(); + + assert.strictEqual(form.$('.o_data_row .o_list_number').text(), '214', + "should have correct order (DESC)"); + + form.destroy(); + }); + QUnit.test('many2many list add *many* records, remove, re-add', function (assert) { assert.expect(5); @@ -2250,7 +2661,7 @@ QUnit.module('relational_fields', { $modal.find('thead input[type=checkbox]').click(); $modal.find('.btn.btn-sm.btn-primary.o_select_button').click(); - + pager_limit = form.$('.o_field_many2many.o_field_widget.o_field_x2many.o_field_x2many_list .o_pager_limit'); assert.equal(pager_limit.text(), '51', 'We should have 51 records in the m2m field'); @@ -2260,6 +2671,169 @@ QUnit.module('relational_fields', { QUnit.module('FieldOne2Many'); + QUnit.test('New record with a o2m also with 2 new records, ordered, and resequenced', function (assert) { + assert.expect(3); + + // Needed to have two new records in a single stroke + this.data.partner.onchanges = { + foo: function(obj) { + obj.p = [ + [5], + [0, 0, {trululu: false}], + [0, 0, {trululu: false}], + ] + } + }; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '', + viewOptions: { + mode: 'create', + }, + mockRPC: function (route, args) { + assert.step(args.method + ' ' + args.model) + return this._super(route, args); + }, + }); + + // change the int_field through drag and drop + // that way, we'll trigger the sorting and the name_get + // of the lines of "p" + testUtils.dragAndDrop( + form.$('.ui-sortable-handle').eq(1), + form.$('tbody tr').first(), + {position: 'top'} + ); + + // Only those two should have been called + // name_get on trululu would trigger an traceback + assert.verifySteps(['default_get partner', 'onchange partner']); + + form.destroy(); + }); + + QUnit.test('O2M List with pager, decoration and default_order: add and cancel adding', function (assert) { + assert.expect(3); + + // The decoration on the list implies that its condition will be evaluated + // against the data of the field (actual records *displayed*) + // If one data is wrongly formed, it will crash + // This test adds then cancels a record in a paged, ordered, and decorated list + // That implies prefetching of records for sorting + // and evaluation of the decoration against *visible records* + + this.data.partner.records[0].p = [2,4]; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'
' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + res_id: 1, + viewOptions: { + mode: 'edit', + }, + }); + + form.$('.o_field_x2many_list .o_field_x2many_list_row_add a').click(); + + var $dataRows = form.$('.o_field_x2many_list .o_data_row'); + assert.equal($dataRows.length, 2, + 'There should be 2 rows'); + + var $expectedSelectedRow = $dataRows.eq(1); + var $actualSelectedRow = form.$('.o_selected_row'); + assert.equal($actualSelectedRow[0], $expectedSelectedRow[0], + 'The selected row should be the new one'); + + // Cancel Creation + var escapeKey = $.ui.keyCode.ESCAPE; + $actualSelectedRow.find('input').trigger( + $.Event('keydown', {which: escapeKey, keyCode: escapeKey})); + + $dataRows = form.$('.o_field_x2many_list .o_data_row'); + assert.equal($dataRows.length, 1, + 'There should be 1 row'); + + form.destroy(); + }); + + QUnit.test('O2M with parented m2o and domain on parent.m2o', function (assert) { + assert.expect(3); + + /* records in an o2m can have a m2o pointing to themselves + * in that case, a domain evaluation on that field followed by name_search + * shouldn't send virtual_ids to the server + */ + + this.data.turtle.fields.parent_id = {string: "Parent", type: "many2one", relation: 'turtle'}; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'
' + + '' + + '' + + '' + + '' + + '' + + '
', + archs: { + 'turtle,false,form': '
', + }, + + mockRPC: function(route, args) { + if (route === '/web/dataset/call_kw/turtle/name_search') { + // We are going to pass twice here + // First time, we really have nothing + // Second time, a virtual_id has been created + assert.deepEqual(args.kwargs.args, [['id', 'in', []]]); + } + return this._super(route, args); + } + }); + + form.$('.o_field_x2many_list[name=turtles] .o_field_x2many_list_row_add a').click(); + var $modal = $('.modal-content'); + + var $turtleParent = $modal.find('.o_field_many2one input'); + var $dropdown = $turtleParent.autocomplete('widget'); + + $turtleParent.click(); + + $dropdown.find('li.o_m2o_dropdown_option:contains(Create)').first().mouseenter().click(); + + $modal = $('.modal-content'); + + $modal.eq(1).find('.modal-footer .btn-primary').eq(0).click(); // Confirm new Record + $modal.eq(0).find('.modal-footer .btn-primary').eq(1).click(); // Save & New + + assert.equal(form.$('.o_data_row').length, 1, + 'The main record should have the new record in its o2m'); + + $modal = $('.modal-content'); + $modal.find('.o_field_many2one input').click(); + + form.destroy(); + }); + QUnit.test('one2many list editable with cell readonly modifier', function (assert) { assert.expect(4); @@ -4169,7 +4743,6 @@ QUnit.module('relational_fields', { form.destroy(); }); - QUnit.test('one2many list (non editable): edition', function (assert) { assert.expect(12); @@ -5038,6 +5611,32 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('one2many and default_get (with date)', function (assert) { + assert.expect(1); + + this.data.partner.fields.p.default = [ + [0, false, {date: '2017-10-08'}], + ]; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'
' + + '' + + '' + + '' + + '' + + '' + + '
', + }); + + assert.strictEqual(form.$('.o_data_cell').text(), '10/08/2017', + "should correctly display the date"); + + form.destroy(); + }); + QUnit.test('one2many and onchange (with integer)', function (assert) { assert.expect(4); @@ -5597,7 +6196,7 @@ QUnit.module('relational_fields', { rpcCount++; if (args.method === 'write') { assert.deepEqual(args.args[1].p, [[0, args.args[1].p[0][1], { - int_field: 123, product_id: 41 + foo: false, int_field: 123, product_id: 41, }]]); } return this._super(route, args); @@ -6975,7 +7574,7 @@ QUnit.module('relational_fields', { }); QUnit.test('nested x2many default values', function (assert) { - assert.expect(2); + assert.expect(3); var form = createView({ View: FormView, @@ -6990,18 +7589,23 @@ QUnit.module('relational_fields', { '', mockRPC: function (route, args) { if (args.model === 'partner' && args.method === 'default_get') { - return $.when({turtles: [[0, 0, { - partner_ids: [[6, 0, [4]]], - }]]}); + return $.when({ + turtles: [ + [0, 0, {partner_ids: [[6, 0, [4]]]}], + [0, 0, {partner_ids: [[6, 0, [1]]]}], + ], + }); } return this._super.apply(this, arguments); }, }); - assert.strictEqual(form.$('.o_list_view .o_field_many2manytags[name="partner_ids"] .badge').length, 1, - "m2mtags should contain one tag"); + assert.strictEqual(form.$('.o_list_view .o_data_row').length, 2, + "one2many list should contain 2 rows"); + assert.strictEqual(form.$('.o_list_view .o_field_many2manytags[name="partner_ids"] .badge').length, 2, + "m2mtags should contain two tags"); assert.strictEqual(form.$('.o_list_view .o_field_many2manytags[name="partner_ids"] .o_badge_text').text(), - 'aaa', "tag name should have been correctly loaded"); + 'aaafirst record', "tag names should have been correctly loaded"); form.destroy(); }); @@ -8666,6 +9270,208 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('add a line, edit it and "Save & New"', function (assert) { + assert.expect(5); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '
' + + '' + + '' + + '' + + '' + + '', + }); + + assert.strictEqual(form.$('.o_data_row').length, 0, + "there should be no record in the relation"); + + // add a new record + form.$('.o_field_x2many_list_row_add a').click(); + $('.modal .o_field_widget').val('new record').trigger('input'); + $('.modal .modal-footer .btn-primary:first').click(); // Save & Close + + assert.strictEqual(form.$('.o_data_row .o_data_cell').text(), 'new record', + "should display the new record"); + + // reopen freshly added record and edit it + form.$('.o_data_row .o_data_cell').click(); + $('.modal .o_field_widget').val('new record edited').trigger('input'); + + // save it, and choose to directly create another record + $('.modal .modal-footer .btn-primary:nth(1)').click(); // Save & New + + assert.strictEqual($('.modal').length, 1, + "the model should still be open"); + assert.strictEqual($('.modal .o_field_widget').text(), '', + "should have cleared the input"); + + $('.modal .o_field_widget').val('another new record').trigger('input'); + $('.modal .modal-footer .btn-primary:first').click(); // Save & Close + + assert.strictEqual(form.$('.o_data_row .o_data_cell').text(), + 'new record editedanother new record', "should display the two records"); + + form.destroy(); + }); + + QUnit.test('one2many form view with action button', function (assert) { + // once the action button is clicked, the record is reloaded (via the + // on_close handler, executed because the python method does not return + // any action, or an ir.action.act_window_close) ; this test ensures that + // it reloads the fields of the opened view (i.e. the form in this case). + assert.expect(7); + + var data = this.data; + data.partner.records[0].p = [2]; + + var form = createView({ + View: FormView, + model: 'partner', + data: data, + res_id: 1, + arch: '
' + + '' + + '' + + '' + + ' @@ -147,8 +147,9 @@
- -
+ + +
diff --git a/addons/web_editor/static/tests/web_editor_tests.js b/addons/web_editor/static/tests/web_editor_tests.js index 3a0b0344..06dd0989 100644 --- a/addons/web_editor/static/tests/web_editor_tests.js +++ b/addons/web_editor/static/tests/web_editor_tests.js @@ -211,7 +211,7 @@ QUnit.test('html_frame does not crash when saving in readonly', function (assert if (_.str.startsWith(route, '/logo')) { // manually call the callback to simulate that the iframe has // been loaded (note: just the content, not the editor) - window.odoo[$.deparam(route).callback + '_content'].call(); + window.flectra[$.deparam(route).callback + '_content'].call(); return $.when(); } return this._super.apply(this, arguments); @@ -248,7 +248,7 @@ QUnit.test('html_frame does not crash when saving in edit mode (editor not loade if (_.str.startsWith(route, '/logo')) { // manually call the callback to simulate that the iframe has // been partially loaded (just the content, not the editor) - window.odoo[$.deparam(route).callback + '_content'](); + window.flectra[$.deparam(route).callback + '_content'](); return $.when(); } return this._super.apply(this, arguments); diff --git a/addons/web_planner/static/src/js/web_planner_backend.js b/addons/web_planner/static/src/js/web_planner_backend.js index 7b2d24c0..5eb4acca 100644 --- a/addons/web_planner/static/src/js/web_planner_backend.js +++ b/addons/web_planner/static/src/js/web_planner_backend.js @@ -16,6 +16,7 @@ var PlannerLauncher = planner.PlannerLauncher.extend({ return this._rpc({ model: 'web.planner', method: 'search_read', + kwargs: {context: session.user_context}, }) .then(function (records) { _.each(records, function (planner) { diff --git a/addons/web_planner/static/src/js/web_planner_common.js b/addons/web_planner/static/src/js/web_planner_common.js index 5be077ef..c7ff92e3 100644 --- a/addons/web_planner/static/src/js/web_planner_common.js +++ b/addons/web_planner/static/src/js/web_planner_common.js @@ -43,10 +43,6 @@ var PlannerDialog = Dialog.extend({ e.preventDefault(); this._display_page($(e.currentTarget).attr("href").replace("#", "")); }, - "click a[href^=\"#show_enterprise\"]": function (e) { - e.preventDefault(); - this.show_enterprise(); - }, }, init: function (parent, options, planner) { this._super.apply(this, arguments); @@ -363,36 +359,6 @@ var PlannerDialog = Dialog.extend({ args: [this.planner.id, {'data': JSON.stringify(this.planner.data), 'progress': this.planner.progress}], }); }, - show_enterprise: function () { - var buttons = [{ - text: _t("Upgrade now"), - classes: 'btn-primary', - close: true, - click: function () { - rpc.query({ - model: "res.users", - method: "search_count", - args: [[["share", "=", false]]], - }) - .then(function (data) { - window.location = "https://www.flectra.com/flectra-enterprise/upgrade?utm_medium=community_upgrade&num_users=" + data; - }); - }, - }, { - text: _t("Cancel"), - close: true, - }]; - var dialog = new Dialog(this, { - size: 'medium', - buttons: buttons, - $content: $('
', { - html: QWeb.render('EnterpriseUpgrade'), - }), - title: _t("Flectra Enterprise"), - }).open(); - - return dialog; - }, }); var PlannerLauncher = Widget.extend({ diff --git a/addons/web_settings_dashboard/__manifest__.py b/addons/web_settings_dashboard/__manifest__.py index 8b6c84bb..80f91c2a 100644 --- a/addons/web_settings_dashboard/__manifest__.py +++ b/addons/web_settings_dashboard/__manifest__.py @@ -2,14 +2,14 @@ # Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. { - 'name': 'Odoo Settings Dashboard', + 'name': 'Flectra Settings Dashboard', 'author': 'Odoo S.A.', 'version': '1.0', 'summary': 'Quick actions for installing new app, adding users, completing planners, etc.', 'category': 'Extra Tools', 'description': """ -Odoo dashboard +Flectra dashboard ============== * Quick access to install apps * Quick users add diff --git a/addons/web_settings_dashboard/static/src/js/dashboard.js b/addons/web_settings_dashboard/static/src/js/dashboard.js index 791b9396..f412d53e 100644 --- a/addons/web_settings_dashboard/static/src/js/dashboard.js +++ b/addons/web_settings_dashboard/static/src/js/dashboard.js @@ -231,7 +231,6 @@ var DashboardApps = Widget.extend({ events: { 'click .o_browse_apps': 'on_new_apps', - 'click .o_confirm_upgrade': 'confirm_upgrade', }, init: function(parent, data){ @@ -242,18 +241,11 @@ var DashboardApps = Widget.extend({ start: function() { this._super.apply(this, arguments); - if (flectra.db_info && _.last(flectra.db_info.server_version_info) !== 'e') { - $(QWeb.render("DashboardEnterprise")).appendTo(this.$el); - } }, on_new_apps: function(){ this.do_action('base.open_module_tree'); }, - - confirm_upgrade: function() { - framework.redirect("https://www.flectra.com/flectra-enterprise/upgrade?num_users=" + (this.data.enterprise_users || 1)); - }, }); var DashboardShare = Widget.extend({ @@ -268,7 +260,7 @@ var DashboardShare = Widget.extend({ init: function(parent, data){ this.data = data; this.parent = parent; - this.share_url = 'https://www.flectra.com'; + this.share_url = 'https://www.flectrahq.com'; this.share_text = encodeURIComponent("I am using #Flectra - Awesome open source business apps."); }, @@ -283,7 +275,7 @@ var DashboardShare = Widget.extend({ }, share_linkedin: function(){ - var popup_url = _.str.sprintf('http://www.linkedin.com/shareArticle?mini=true&url=%s&title=I am using flectra&summary=%s&source=www.flectra.com', encodeURIComponent(this.share_url), this.share_text); + var popup_url = _.str.sprintf('http://www.linkedin.com/shareArticle?mini=true&url=%s&title=I am using flectra&summary=%s&source=www.flectrahq.com', encodeURIComponent(this.share_url), this.share_text); this.sharer(popup_url); }, diff --git a/addons/web_settings_dashboard/static/src/xml/dashboard.xml b/addons/web_settings_dashboard/static/src/xml/dashboard.xml index 2351ab90..c158cfc1 100644 --- a/addons/web_settings_dashboard/static/src/xml/dashboard.xml +++ b/addons/web_settings_dashboard/static/src/xml/dashboard.xml @@ -105,7 +105,7 @@
- Need more help? Browse the documentation. + Need more help? Browse the documentation.
@@ -179,20 +179,6 @@
- -
-
- -
-
-
diff --git a/addons/website/controllers/main.py b/addons/website/controllers/main.py index 8fc8d1b3..ae31fec9 100644 --- a/addons/website/controllers/main.py +++ b/addons/website/controllers/main.py @@ -18,8 +18,9 @@ from flectra import http, models, fields, _ from flectra.http import request from flectra.tools import pycompat, OrderedSet from flectra.addons.http_routing.models.ir_http import slug, _guess_mimetype -from flectra.addons.web.controllers.main import WebClient, Binary, Home +from flectra.addons.web.controllers.main import WebClient, Binary from flectra.addons.portal.controllers.portal import pager as portal_pager +from flectra.addons.portal.controllers.web import Home logger = logging.getLogger(__name__) @@ -306,17 +307,6 @@ class Website(Home): modules.button_immediate_upgrade() return request.redirect(redirect) - @http.route('/website/translations', type='json', auth="public", website=True) - def get_website_translations(self, lang, mods=None): - Modules = request.env['ir.module.module'].sudo() - modules = Modules.search([ - '|', ('name', 'ilike', 'website'), ('name', '=', 'web_editor'), - ('state', '=', 'installed') - ]).mapped('name') - if mods: - modules += mods - return WebClient().translations(mods=modules, lang=lang) - @http.route(['/website/publish'], type='json', auth="public", website=True) def publish(self, id, object): Model = request.env[object] diff --git a/addons/website/data/website_demo.xml b/addons/website/data/website_demo.xml index 5b189e75..7d3c68ce 100644 --- a/addons/website/data/website_demo.xml +++ b/addons/website/data/website_demo.xml @@ -130,5 +130,118 @@ response = request.render("website.template_partner_comment", { Website 0.0.0.0 0.0.0.0 + + + Home + qweb + website2.homepage + + + +
+ +
+
+
+
+
+ + True + / + + + + + + Contact Us + qweb + website2.contactus + + + +
+
+
+

Contact us

+
+
+
+
+

Contact us about anything related to our company or services.

+

We'll do our best to get back to you as soon as possible.

+
+
+ +
+
+ +
+
+
+
+
+ + + + + + True + /contactus + + + + + + + + + + Top Menu + + + + Home + / + + 10 + + + + + Contact us + /contactus + + 60 + + + diff --git a/addons/website/models/ir_http.py b/addons/website/models/ir_http.py index be0a3ebd..000aaaa4 100644 --- a/addons/website/models/ir_http.py +++ b/addons/website/models/ir_http.py @@ -6,6 +6,7 @@ import traceback import os import unittest +import pytz import werkzeug import werkzeug.routing import werkzeug.utils @@ -17,7 +18,7 @@ from flectra.http import request from flectra.tools import config from flectra.exceptions import QWebException from flectra.tools.safe_eval import safe_eval -from flectra.osv.expression import FALSE_DOMAIN +from flectra.osv.expression import FALSE_DOMAIN, OR from flectra.addons.http_routing.models.ir_http import ModelConverter, _guess_mimetype from flectra.addons.portal.controllers.portal import _build_url_w_params @@ -74,6 +75,10 @@ class Http(models.AbstractModel): context = {} if not request.context.get('tz'): context['tz'] = request.session.get('geoip', {}).get('time_zone') + try: + pytz.timezone(context['tz'] or '') + except pytz.UnknownTimeZoneError: + context.pop('tz') request.website = request.env['website'].get_current_website() # can use `request.env` since auth methods are called context['website_id'] = request.website.id @@ -104,6 +109,11 @@ class Http(models.AbstractModel): return request.website.default_lang_id return super(Http, cls)._get_default_lang() + @classmethod + def _get_translation_frontend_modules_domain(cls): + domain = super(Http, cls)._get_translation_frontend_modules_domain() + return OR([domain, [('name', 'ilike', 'website')]]) + @classmethod def _serve_page(cls): req_page = request.httprequest.path diff --git a/addons/website/models/ir_model.py b/addons/website/models/ir_model.py index c1f32040..0dadd9bf 100644 --- a/addons/website/models/ir_model.py +++ b/addons/website/models/ir_model.py @@ -13,8 +13,8 @@ class IrModel(models.Model): @api.multi def unlink(self): - self.env.cr.execute( - "DELETE FROM ir_model_fields WHERE name='website_id'") + self.env.cr.execute("DELETE FROM ir_model_fields WHERE name='website_id'") + self.env.cr.execute("DELETE FROM res_config_settings WHERE website_id IS NOT NULL") return super(IrModel, self).unlink() diff --git a/addons/website/models/ir_qweb.py b/addons/website/models/ir_qweb.py index c6f3b7dc..ef98b608 100644 --- a/addons/website/models/ir_qweb.py +++ b/addons/website/models/ir_qweb.py @@ -1,93 +1,51 @@ # -*- coding: utf-8 -*- # Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. - -import ast +import re +from collections import OrderedDict from flectra import models from flectra.http import request +re_background_image = re.compile(r"(background-image\s*:\s*url\(\s*['\"]?\s*)([^)'\"]+)") + + class QWeb(models.AbstractModel): """ QWeb object for rendering stuff in the website context """ _inherit = 'ir.qweb' URL_ATTRS = { - 'form': 'action', - 'a': 'href', + 'form': 'action', + 'a': 'href', + 'link': 'href', + 'script': 'src', + 'img': 'src', } - CDN_TRIGGERS = { - 'link': 'href', - 'script': 'src', - 'img': 'src', - } - - def _get_asset(self, xmlid, options, css=True, js=True, debug=False, async=False, values=None): - website = getattr(request, 'website', None) if request else None - if website and website.cdn_activated: - values = dict(values or {}, url_for=website.get_cdn_url) - return super(QWeb, self)._get_asset(xmlid, options, css, js, debug, async, values) - - def _website_build_attribute(self, tagName, name, value, options, values): - """ Compute the value of an attribute while rendering the template. """ - if name == self.URL_ATTRS.get(tagName) and values.get('url_for'): - return values.get('url_for')(value or '') - elif request and getattr(request, 'website', None) and request.website.cdn_activated and (name == self.URL_ATTRS.get(tagName) or name == self.CDN_TRIGGERS.get(tagName)): - return request.website.get_cdn_url(value or '') - return value - - def _wrap_build_attributes(self, el, items, options): - """ Map items corresponding to URL and CDN attributes to an ast expression. """ - if options.get('rendering_bundle'): - return items - - url_att = self.URL_ATTRS.get(el.tag) - cdn_att = self.CDN_TRIGGERS.get(el.tag) - - def process(item): - if isinstance(item, tuple) and (item[0] in (url_att, cdn_att)): - return (item[0], ast.Call( - func=ast.Attribute( - value=ast.Name(id='self', ctx=ast.Load()), - attr='_website_build_attribute', - ctx=ast.Load() - ), - args=[ - ast.Str(el.tag), - ast.Str(item[0]), - item[1], - ast.Name(id='options', ctx=ast.Load()), - ast.Name(id='values', ctx=ast.Load()), - ], keywords=[], - starargs=None, kwargs=None - )) - else: - return item - - return [process(it) for it in items] - - def _compile_static_attributes(self, el, options): - items = super(QWeb, self)._compile_static_attributes(el, options) - return self._wrap_build_attributes(el, items, options) - - def _compile_dynamic_attributes(self, el, options): - items = super(QWeb, self)._compile_dynamic_attributes(el, options) - return self._wrap_build_attributes(el, items, options) - - # method called by computing code - - def _get_dynamic_att(self, tagName, atts, options, values): - atts = super(QWeb, self)._get_dynamic_att(tagName, atts, options, values) - if options.get('rendering_bundle'): + def _post_processing_att(self, tagName, atts, options): + if atts.get('data-no-post-process'): return atts - for name, value in atts.items(): - atts[name] = self._website_build_attribute(tagName, name, value, options, values) - return atts - def _is_static_node(self, el): - url_att = self.URL_ATTRS.get(el.tag) - cdn_att = self.CDN_TRIGGERS.get(el.tag) - return super(QWeb, self)._is_static_node(el) and \ - (not url_att or not el.get(url_att)) and \ - (not cdn_att or not el.get(cdn_att)) + atts = super(QWeb, self)._post_processing_att(tagName, atts, options) + + if options.get('inherit_branding') or options.get('rendering_bundle') or \ + options.get('edit_translations') or options.get('debug') or (request and request.debug): + return atts + + website = request and getattr(request, 'website', None) + if not website and options.get('website_id'): + website = self.env['website'].browse(options['website_id']) + + if not website or not website.cdn_activated: + return atts + + name = self.URL_ATTRS.get(tagName) + if name and name in atts: + atts = OrderedDict(atts) + atts[name] = website.get_cdn_url(atts[name]) + if isinstance(atts.get('style'), str) and 'background-image' in atts['style']: + atts = OrderedDict(atts) + atts['style'] = re_background_image.sub(lambda m: '%s%s' % (m.group(1), website.get_cdn_url(m.group(2))), atts['style']) + + return atts diff --git a/addons/website/models/website.py b/addons/website/models/website.py index 1bb4a949..db2237e5 100644 --- a/addons/website/models/website.py +++ b/addons/website/models/website.py @@ -102,6 +102,10 @@ class Website(models.Model): @api.multi def write(self, values): self._get_languages.clear_cache(self) + result = super(Website, self).write(values) + if 'cdn_activated' in values or 'cdn_url' in values or 'cdn_filters' in values: + # invalidate the caches from static node at compile time + self.env['ir.qweb'].clear_caches() if values.get('website_code') or \ (values.get('is_default_website') and self != self.env.ref('website.default_website')): @@ -112,7 +116,7 @@ class Website(models.Model): '- If above action is not properly done ' 'then it will break your current ' 'multi website feature.')) - return super(Website, self).write(values) + return result @api.model def create(self, values): @@ -660,15 +664,15 @@ class Website(models.Model): size = '' if size is None else '/%s' % size return '/web/image/%s/%s/%s%s?unique=%s' % (record._name, record.id, field, size, sha) - @api.model def get_cdn_url(self, uri): - # Currently only usable in a website_enable request context - if request and request.website and not request.debug and request.website.user_id.id == request.uid: - cdn_url = request.website.cdn_url - cdn_filters = (request.website.cdn_filters or '').splitlines() - for flt in cdn_filters: - if flt and re.match(flt, uri): - return urls.url_join(cdn_url, uri) + self.ensure_one() + if not uri: + return '' + cdn_url = self.cdn_url + cdn_filters = (self.cdn_filters or '').splitlines() + for flt in cdn_filters: + if flt and re.match(flt, uri): + return urls.url_join(cdn_url, uri) return uri @api.model @@ -989,9 +993,10 @@ class Menu(models.Model): for menu in data['data']: menu_id = self.browse(menu['id']) # if the url match a website.page, set the m2o relation - page = self.env['website.page'].search([('url', '=', menu['url'])], limit=1) + page = self.env['website.page'].search(['|', ('url', '=', menu['url']), ('url', '=', '/' + menu['url'])], limit=1) if page: menu['page_id'] = page.id + menu['url'] = page.url elif menu_id.page_id: menu_id.page_id.write({'url': menu['url']}) if 'is_homepage' in menu: diff --git a/addons/website/static/src/img/flectra.jpg b/addons/website/static/src/img/flectra.jpg index e9c5ac23..0b7c74fe 100644 Binary files a/addons/website/static/src/img/flectra.jpg and b/addons/website/static/src/img/flectra.jpg differ diff --git a/addons/website/static/src/js/backend/dashboard.js b/addons/website/static/src/js/backend/dashboard.js index a3f8b3e3..9ebbcd9e 100644 --- a/addons/website/static/src/js/backend/dashboard.js +++ b/addons/website/static/src/js/backend/dashboard.js @@ -293,6 +293,7 @@ var Dashboard = Widget.extend(ControlPanelMixin, { self.handle_analytics_auth($analytics_components); gapi.analytics.auth.on('signIn', function() { + delete window.onOriginError; self.handle_analytics_auth($analytics_components); }); diff --git a/addons/website/static/src/js/menu/new_content.js b/addons/website/static/src/js/menu/new_content.js index d3576407..a2ac21f6 100644 --- a/addons/website/static/src/js/menu/new_content.js +++ b/addons/website/static/src/js/menu/new_content.js @@ -15,6 +15,7 @@ var NewContentMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({ }), events: _.extend({}, websiteNavbarData.WebsiteNavbarActionWidget.prototype.events || {}, { 'click > a': '_onMenuToggleClick', + 'click > #o_new_content_menu_choices': '_onBackgroundClick', }), /** @@ -73,6 +74,15 @@ var NewContentMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({ ev.preventDefault(); this.$newContentMenuChoices.toggleClass('o_hidden'); }, + /** + * Called when a click outside the menu's options occurs -> Closes the menu + * + * @private + * @param {Event} ev + */ + _onBackgroundClick: function (ev) { + this.$newContentMenuChoices.addClass('o_hidden'); + }, }); websiteNavbarData.websiteNavbarRegistry.add(NewContentMenu, '.o_new_content_menu'); diff --git a/addons/website/static/src/less/website.ui.components.less b/addons/website/static/src/less/website.ui.components.less index a9053eee..ed3a4217 100644 --- a/addons/website/static/src/less/website.ui.components.less +++ b/addons/website/static/src/less/website.ui.components.less @@ -44,7 +44,7 @@ body .modal { .o-w-preserve-modals(); .o-w-preserve-tabs(); - button.close { + &:not(.oe_mobile_preview) button.close { .o-w-close-icon(12px); margin-right: -2px; } @@ -481,4 +481,4 @@ body .modal { opacity: 0.5; } } -} \ No newline at end of file +} diff --git a/addons/website/tests/__init__.py b/addons/website/tests/__init__.py index f4befaea..b2472c38 100644 --- a/addons/website/tests/__init__.py +++ b/addons/website/tests/__init__.py @@ -2,6 +2,7 @@ # Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. from . import test_converter +from . import test_qweb from . import test_crawl from . import test_ui from . import test_views diff --git a/addons/website/tests/template_qweb_test.xml b/addons/website/tests/template_qweb_test.xml new file mode 100644 index 00000000..be1d02b7 --- /dev/null +++ b/addons/website/tests/template_qweb_test.xml @@ -0,0 +1,36 @@ + + + + + + + \ No newline at end of file diff --git a/addons/website/tests/test_qweb.py b/addons/website/tests/test_qweb.py new file mode 100644 index 00000000..dc33c619 --- /dev/null +++ b/addons/website/tests/test_qweb.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import re + +from flectra import tools +from flectra.modules.module import get_module_resource +from flectra.tests.common import TransactionCase + + +class TestQweb(TransactionCase): + def _load(self, module, *args): + tools.convert_file(self.cr, 'website', + get_module_resource(module, *args), + {}, 'init', False, 'test', self.registry._assertion_report) + + def test_qweb_cdn(self): + self._load('website', 'tests', 'template_qweb_test.xml') + + website = self.env['website'].browse(1) + website.write({ + "cdn_activated": True, + "cdn_url": "http://test.cdn" + }) + + demo = self.env['res.users'].search([('login', '=', 'demo')])[0] + demo.write({"signature": ''' + span + '''}) + + demo_env = self.env(user=demo) + + html = demo_env['ir.qweb'].render('website.test_template', {"user": demo}, website_id= website.id) + html = html.strip().decode('utf8') + html = re.sub(r'\?unique=[^"]+', '', html).encode('utf8') + + attachments = demo_env['ir.attachment'].search([('url', '=like', '/web/content/%-%/website.test_bundle.%')]) + self.assertEqual(len(attachments), 2) + self.assertEqual(html, (""" + + + + + + + + + + + + + + x + x + xxx +
+ span +
+
+ +""" % { + "js": attachments[0].url, + "css": attachments[1].url, + "user_id": demo.id, + }).encode('utf8')) diff --git a/addons/website/views/res_config_settings_views.xml b/addons/website/views/res_config_settings_views.xml index 8edf707a..03beb024 100644 --- a/addons/website/views/res_config_settings_views.xml +++ b/addons/website/views/res_config_settings_views.xml @@ -10,6 +10,7 @@
+

Website

diff --git a/addons/website/views/website_templates.xml b/addons/website/views/website_templates.xml index 90aa9c54..65a87ab9 100644 --- a/addons/website/views/website_templates.xml +++ b/addons/website/views/website_templates.xml @@ -74,7 +74,7 @@ -
  • diff --git a/addons/website_blog/models/website_blog.py b/addons/website_blog/models/website_blog.py index 786ca6d2..3eb74fd9 100644 --- a/addons/website_blog/models/website_blog.py +++ b/addons/website_blog/models/website_blog.py @@ -172,10 +172,7 @@ class BlogPost(models.Model): blog_post.teaser = blog_post.teaser_manual else: content = html2plaintext(blog_post.content).replace('\n', ' ') - blog_post.teaser = ' '.join(itertools.islice( - (c for c in content.split(' ') if c), - 50 - )) + '...' + blog_post.teaser = content[:150] + '...' @api.multi def _set_teaser(self): diff --git a/addons/website_blog/static/src/js/website_blog.editor.js b/addons/website_blog/static/src/js/website_blog.editor.js index 9d05962d..54c2098d 100644 --- a/addons/website_blog/static/src/js/website_blog.editor.js +++ b/addons/website_blog/static/src/js/website_blog.editor.js @@ -132,7 +132,11 @@ options.registry.blog_cover = options.Class.extend({ this.$image.css("background-image", ""); }, change: function (previewMode, value, $li) { - var $image = $("", {src: this.$image.css("background-image")}); + var $image = $(""); + var background = this.$image.css("background-image"); + if (background && background !== "none") { + $image.attr('src', background.match(/^url\(["']?(.+?)["']?\)$/)[1]); + } var editor = new widget.MediaDialog(this, {only_images: true}, $image, $image[0]).open(); editor.on("save", this, function (event, img) { diff --git a/addons/website_blog/views/website_blog_templates.xml b/addons/website_blog/views/website_blog_templates.xml index 3d617341..94790ae8 100644 --- a/addons/website_blog/views/website_blog_templates.xml +++ b/addons/website_blog/views/website_blog_templates.xml @@ -101,6 +101,7 @@ +
    diff --git a/addons/website_crm_phone_validation/controllers/website_form.py b/addons/website_crm_phone_validation/controllers/website_form.py index c614f235..7c8d57f4 100644 --- a/addons/website_crm_phone_validation/controllers/website_form.py +++ b/addons/website_crm_phone_validation/controllers/website_form.py @@ -4,8 +4,6 @@ from flectra.addons.website_form.controllers.main import WebsiteForm from flectra.http import request, route -import json - class WebsiteForm(WebsiteForm): diff --git a/addons/website_event_sale/controllers/main.py b/addons/website_event_sale/controllers/main.py index 2915685a..1bafb357 100644 --- a/addons/website_event_sale/controllers/main.py +++ b/addons/website_event_sale/controllers/main.py @@ -39,7 +39,7 @@ class WebsiteEventSaleController(WebsiteEventController): # free tickets -> order with amount = 0: auto-confirm, no checkout if not order.amount_total: order.action_confirm() # tde notsure: email sending ? - attendees = request.env['event.registration'].browse(list(attendee_ids)) + attendees = request.env['event.registration'].browse(list(attendee_ids)).sudo() # clean context and session, then redirect to the confirmation page request.website.sale_reset() return request.render("website_event.registration_complete", { diff --git a/addons/website_event_sale/static/src/js/website.tour.event_sale.js b/addons/website_event_sale/static/src/js/website.tour.event_sale.js index 16091d10..be44dc57 100644 --- a/addons/website_event_sale/static/src/js/website.tour.event_sale.js +++ b/addons/website_event_sale/static/src/js/website.tour.event_sale.js @@ -92,6 +92,7 @@ tour.register('event_buy_tickets', { { content: "Last step", trigger: '.oe_website_sale:contains("Thank you for your order")', + timeout: 30000, } ] ); diff --git a/addons/website_event_sale/tests/test_ui.py b/addons/website_event_sale/tests/test_ui.py index 87bf7de4..30213c07 100644 --- a/addons/website_event_sale/tests/test_ui.py +++ b/addons/website_event_sale/tests/test_ui.py @@ -12,7 +12,7 @@ class TestUi(flectra.tests.HttpCase): # - that main demo company is gelocated in US # - that this test awaits for hardcoded USDs amount # we have to force company currency as USDs only for this test - self.env.ref('base.main_company').write({'currency_id': self.env.ref('base.USD').id}) + self.cr.execute("UPDATE res_company SET currency_id = %s WHERE id = %s", [self.env.ref('base.USD').id, self.env.ref('base.main_company').id]) self.phantom_js("/", "flectra.__DEBUG__.services['web_tour.tour'].run('event_buy_tickets')", "flectra.__DEBUG__.services['web_tour.tour'].tours.event_buy_tickets.ready", login="admin") def test_demo(self): diff --git a/addons/website_event_track/views/event_track_templates.xml b/addons/website_event_track/views/event_track_templates.xml index 69d293f1..dfa41b83 100644 --- a/addons/website_event_track/views/event_track_templates.xml +++ b/addons/website_event_track/views/event_track_templates.xml @@ -50,8 +50,8 @@
    - - + +
    diff --git a/addons/website_form/controllers/main.py b/addons/website_form/controllers/main.py index 43d8ec0c..bff88d26 100644 --- a/addons/website_form/controllers/main.py +++ b/addons/website_form/controllers/main.py @@ -112,6 +112,7 @@ class WebsiteForm(http.Controller): 'record': {}, # Values to create record 'attachments': [], # Attached files 'custom': '', # Custom fields values + 'meta': '', # Add metadata if enabled } authorized_fields = model.sudo()._get_form_writable_fields() diff --git a/addons/website_form/static/src/js/website_form.js b/addons/website_form/static/src/js/website_form.js index 7aa3303f..1f358742 100644 --- a/addons/website_form/static/src/js/website_form.js +++ b/addons/website_form/static/src/js/website_form.js @@ -20,7 +20,11 @@ flectra.define('website_form.animation', function (require) { return $.when(this._super.apply(this, arguments), def); }, - start: function () { + start: function (editable_mode) { + if (editable_mode) { + this.stop(); + return; + } var self = this; this.templates_loaded = ajax.loadXML('/website_form/static/src/xml/website_form.xml', qweb); this.$target.find('.o_website_form_send').on('click',function (e) {self.send(e);}); diff --git a/addons/website_livechat/controllers/main.py b/addons/website_livechat/controllers/main.py index 70e1107e..158d6d07 100644 --- a/addons/website_livechat/controllers/main.py +++ b/addons/website_livechat/controllers/main.py @@ -20,13 +20,17 @@ class WebsiteLivechat(http.Controller): @http.route('/livechat/channel/', type='http', auth='public', website=True) def channel_rating(self, channel, **kw): # get the last 100 ratings and the repartition per grade - ratings = request.env['rating.rating'].search([('res_model', '=', 'mail.channel'), ('res_id', 'in', channel.sudo().channel_ids.ids)], order='create_date desc', limit=100) - repartition = channel.sudo().channel_ids.rating_get_grades() + domain = [ + ('res_model', '=', 'mail.channel'), ('res_id', 'in', channel.sudo().channel_ids.ids), + ('consumed', '=', True), ('rating', '>=', 1), + ] + ratings = request.env['rating.rating'].search(domain, order='create_date desc', limit=100) + repartition = channel.sudo().channel_ids.rating_get_grades(domain=domain) # compute percentage percentage = dict.fromkeys(['great', 'okay', 'bad'], 0) for grade in repartition: - percentage[grade] = repartition[grade] * 100 / sum(repartition.values()) if sum(repartition.values()) else 0 + percentage[grade] = round(repartition[grade] * 100.0 / sum(repartition.values()), 1) if sum(repartition.values()) else 0 # the value dict to render the template values = { diff --git a/addons/website_mail_channel/static/src/js/website_mail_channel.snippet.js b/addons/website_mail_channel/static/src/js/website_mail_channel.snippet.js index d61d1975..151ca0f8 100644 --- a/addons/website_mail_channel/static/src/js/website_mail_channel.snippet.js +++ b/addons/website_mail_channel/static/src/js/website_mail_channel.snippet.js @@ -8,6 +8,7 @@ sAnimation.registry.follow_alias = sAnimation.Class.extend({ start: function () { var self = this; this.is_user = false; + var unsubscribePage = window.location.search.slice(1).split('&').indexOf("unsubscribe") >= 0; this._rpc({ route: '/groups/is_member', params: { @@ -19,6 +20,9 @@ sAnimation.registry.follow_alias = sAnimation.Class.extend({ self.is_user = data.is_user; self.email = data.email; self.$target.find('.js_mg_link').attr('href', '/groups/' + self.$target.data('id')); + if (unsubscribePage && self.is_user) { + self.$target.find(".js_mg_follow_form").remove(); + } self.toggle_subscription(data.is_member ? 'on' : 'off', data.email); self.$target.removeClass("hidden"); }); diff --git a/addons/website_mass_mailing/controllers/main.py b/addons/website_mass_mailing/controllers/main.py index 80ae4304..a711f746 100644 --- a/addons/website_mass_mailing/controllers/main.py +++ b/addons/website_mass_mailing/controllers/main.py @@ -11,7 +11,7 @@ class MassMailController(MassMailController): def mailing(self, mailing_id, email=None, res_id=None, **post): mailing = request.env['mail.mass_mailing'].sudo().browse(mailing_id) if mailing.exists(): - if mailing.mailing_model_name == 'mail.mass_mailing.contact': + if mailing.mailing_model_real == 'mail.mass_mailing.contact': contacts = request.env['mail.mass_mailing.contact'].sudo().search([('email', '=', email)]) return request.render('website_mass_mailing.page_unsubscribe', { 'contacts': contacts, diff --git a/addons/website_quote/__init__.py b/addons/website_quote/__init__.py index b8554620..96e094d9 100644 --- a/addons/website_quote/__init__.py +++ b/addons/website_quote/__init__.py @@ -3,3 +3,12 @@ from . import controllers from . import models + +from flectra.api import Environment, SUPERUSER_ID + +def _install_sale_payment(cr, registry): + env = Environment(cr, SUPERUSER_ID, {}) + env['ir.module.module'].search([ + ('name', '=', 'sale_payment'), + ('state', '=', 'uninstalled'), + ]).button_install() diff --git a/addons/website_quote/__manifest__.py b/addons/website_quote/__manifest__.py index 4b5d0961..cca3834a 100644 --- a/addons/website_quote/__manifest__.py +++ b/addons/website_quote/__manifest__.py @@ -25,4 +25,8 @@ ], 'qweb': ['static/src/xml/*.xml'], 'installable': True, + + # needed because dependencies can't be changed in a stable version + # TODO in master: add sale_payment to depends and remove this + 'post_init_hook': '_install_sale_payment', } diff --git a/addons/website_quote/controllers/main.py b/addons/website_quote/controllers/main.py index b23b186c..ed6a0771 100644 --- a/addons/website_quote/controllers/main.py +++ b/addons/website_quote/controllers/main.py @@ -8,6 +8,7 @@ from flectra.http import request from flectra.addons.portal.controllers.portal import get_records_pager from flectra.addons.sale.controllers.portal import CustomerPortal from flectra.addons.portal.controllers.mail import _message_post_helper +from flectra.osv import expression class CustomerPortal(CustomerPortal): @@ -49,7 +50,7 @@ class sale_quote(http.Controller): if Order and request.session.get('view_quote_%s' % Order.id) != now and request.env.user.share: request.session['view_quote_%s' % Order.id] = now body = _('Quotation viewed by customer') - _message_post_helper(res_model='sale.order', res_id=Order.id, message=body, token=token, message_type='notification', subtype="mail.mt_note", partner_ids=Order.user_id.sudo().partner_id.ids) + _message_post_helper(res_model='sale.order', res_id=Order.id, message=body, token=Order.access_token, message_type='notification', subtype="mail.mt_note", partner_ids=Order.user_id.sudo().partner_id.ids) if not Order: return request.render('website.404') @@ -89,7 +90,11 @@ class sale_quote(http.Controller): } if order_sudo.require_payment or values['need_payment']: - acquirers = request.env['payment.acquirer'].sudo().search([('website_published', '=', True), ('company_id', '=', order_sudo.company_id.id)]) + domain = expression.AND([ + ['&', ('website_published', '=', True), ('company_id', '=', order_sudo.company_id.id)], + ['|', ('specific_countries', '=', False), ('country_ids', 'in', [order_sudo.partner_id.country_id.id])] + ]) + acquirers = request.env['payment.acquirer'].sudo().search(domain) values['form_acquirers'] = [acq for acq in acquirers if acq.payment_flow == 'form' and acq.view_template_id] values['s2s_acquirers'] = [acq for acq in acquirers if acq.payment_flow == 's2s' and acq.registration_view_template_id] diff --git a/addons/website_quote/views/website_quote_templates.xml b/addons/website_quote/views/website_quote_templates.xml index 01215fcb..46536fd8 100644 --- a/addons/website_quote/views/website_quote_templates.xml +++ b/addons/website_quote/views/website_quote_templates.xml @@ -662,7 +662,7 @@