diff options
| author | Christophe Simonis <chs@odoo.com> | 2018-01-03 12:28:52 +0100 |
|---|---|---|
| committer | Christophe Simonis <chs@odoo.com> | 2018-01-03 12:28:52 +0100 |
| commit | 362f0489d07d5bcd3673278e93848d0ea3d177a8 (patch) | |
| tree | ce164613ca633d736bf41c5b3d02b8ee541baa89 | |
| parent | a39491d459f7a70af21bce1f45434bab05f1c284 (diff) | |
| parent | fe22a0f9ca92ef0e76bb2f730a2b22da500db5dd (diff) | |
[MERGE] forward port branch 11.0 up to fe22a0f9ca9
164 files changed, 3167 insertions, 390 deletions
diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py index 567c8504637..a256605c40b 100644 --- a/addons/account/models/account_bank_statement.py +++ b/addons/account/models/account_bank_statement.py @@ -346,7 +346,7 @@ class AccountBankStatement(models.Model): class AccountBankStatementLine(models.Model): _name = "account.bank.statement.line" _description = "Bank Statement Line" - _order = "statement_id desc, sequence, id desc" + _order = "statement_id desc, date desc, sequence, id desc" name = fields.Char(string='Label', required=True) date = fields.Date(required=True, default=lambda self: self._context.get('date', fields.Date.context_today(self))) diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py index 5da37518d19..dacbc7e2df1 100644 --- a/addons/account/models/account_invoice.py +++ b/addons/account/models/account_invoice.py @@ -951,7 +951,7 @@ class AccountInvoice(models.Model): total_currency = 0 for line in invoice_move_lines: if self.currency_id != company_currency: - currency = self.currency_id.with_context(date=self.date or self.date_invoice or fields.Date.context_today(self)) + currency = self.currency_id.with_context(date=self._get_currency_rate_date() or fields.Date.context_today(self)) if not (line.get('currency_id') and line.get('amount_currency')): line['currency_id'] = currency.id line['amount_currency'] = currency.round(line['price']) @@ -1101,7 +1101,7 @@ class AccountInvoice(models.Model): if inv.payment_term_id: totlines = inv.with_context(ctx).payment_term_id.with_context(currency_id=company_currency.id).compute(total, inv.date_invoice)[0] res_amount_currency = total_currency - ctx['date'] = inv.date or inv.date_invoice + ctx['date'] = inv._get_currency_rate_date() for i, t in enumerate(totlines): if inv.currency_id != company_currency: amount_currency = company_currency.with_context(ctx).compute(t[1], inv.currency_id) @@ -1300,6 +1300,9 @@ class AccountInvoice(models.Model): copy_fields = ['company_id', 'user_id', 'fiscal_position_id'] return self._get_refund_common_fields() + self._get_refund_prepare_fields() + copy_fields + def _get_currency_rate_date(self): + return self.date or self.date_invoice + @api.model def _prepare_refund(self, invoice, date_invoice=None, date=None, description=None, journal_id=None): """ Prepare the dict of values to create the new credit note from the invoice. @@ -1460,7 +1463,7 @@ class AccountInvoiceLine(models.Model): @api.one @api.depends('price_unit', 'discount', 'invoice_line_tax_ids', 'quantity', 'product_id', 'invoice_id.partner_id', 'invoice_id.currency_id', 'invoice_id.company_id', - 'invoice_id.date_invoice') + 'invoice_id.date_invoice', 'invoice_id.date') def _compute_price(self): currency = self.invoice_id and self.invoice_id.currency_id or None price = self.price_unit * (1 - (self.discount or 0.0) / 100.0) @@ -1470,7 +1473,7 @@ class AccountInvoiceLine(models.Model): self.price_subtotal = price_subtotal_signed = taxes['total_excluded'] if taxes else self.quantity * price self.price_total = taxes['total_included'] if taxes else self.price_subtotal if self.invoice_id.currency_id and self.invoice_id.currency_id != self.invoice_id.company_id.currency_id: - price_subtotal_signed = self.invoice_id.currency_id.with_context(date=self.invoice_id.date_invoice).compute(price_subtotal_signed, self.invoice_id.company_id.currency_id) + price_subtotal_signed = self.invoice_id.currency_id.with_context(date=self.invoice_id._get_currency_rate_date()).compute(price_subtotal_signed, self.invoice_id.company_id.currency_id) sign = self.invoice_id.type in ['in_refund', 'out_refund'] and -1 or 1 self.price_subtotal_signed = price_subtotal_signed * sign diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index 10a6c97ca05..5018b1e28e2 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -110,7 +110,7 @@ class AccountMove(models.Model): tax_cash_basis_rec_id = fields.Many2one( 'account.partial.reconcile', string='Tax Cash Basis Entry of', - help="Technical field used to keep track of the tax cash basis reconciliation." + help="Technical field used to keep track of the tax cash basis reconciliation. " "This is needed when cancelling the source: it will post the inverse journal entry to cancel that part too.") auto_reverse = fields.Boolean(string='Reverse Automatically', default=False, help='If this checkbox is ticked, this entry will be automatically reversed at the reversal date you defined.') reverse_date = fields.Date(string='Reversal Date', help='Date of the reverse accounting entry.') @@ -174,6 +174,8 @@ class AccountMove(models.Model): if not move.journal_id.update_posted: raise UserError(_('You cannot modify a posted entry of this journal.\nFirst you should set the journal to allow cancelling entries.')) if self.ids: + self.check_access_rights('write') + self.check_access_rule('write') self._check_lock_date() self._cr.execute('UPDATE account_move '\ 'SET state=%s '\ @@ -544,9 +546,9 @@ class AccountMoveLine(models.Model): for line in self: amount = line.amount_currency if line.currency_id and line.currency_id != line.company_currency_id: - amount = self.currency_id.with_context(date=line.date).compute(amount, line.company_currency_id) - line.debit = amount > 0 and amount or 0.0 - line.credit = amount < 0 and -amount or 0.0 + amount = line.currency_id.with_context(date=line.date).compute(amount, line.company_currency_id) + line.debit = amount > 0 and amount or 0.0 + line.credit = amount < 0 and -amount or 0.0 #################################################### # Reconciliation interface methods diff --git a/addons/account/views/account_report.xml b/addons/account/views/account_report.xml index 6328d865c2c..06e077e7d8c 100644 --- a/addons/account/views/account_report.xml +++ b/addons/account/views/account_report.xml @@ -74,7 +74,7 @@ <report id="action_report_journal" model="account.common.journal.report" - string="Sale/Purchase Journal" + string="Journals Audit" report_type="qweb-pdf" name="account.report_journal" file="account.report_journal" diff --git a/addons/account/views/report_overdue.xml b/addons/account/views/report_overdue.xml index bb421333cb6..002ce58ef9b 100644 --- a/addons/account/views/report_overdue.xml +++ b/addons/account/views/report_overdue.xml @@ -16,7 +16,7 @@ </p> <t t-if="Lines[o.id]"> - <span t-field="res_company.overdue_msg"/> + <span t-field="res_company.with_context(lang=o.lang).overdue_msg"/> <table class="table table-condensed" t-foreach="Lines[o.id]" t-as="currency"> <thead> <tr> diff --git a/addons/account/wizard/account_report_print_journal_view.xml b/addons/account/wizard/account_report_print_journal_view.xml index f549e683934..5103f6aabea 100644 --- a/addons/account/wizard/account_report_print_journal_view.xml +++ b/addons/account/wizard/account_report_print_journal_view.xml @@ -2,7 +2,7 @@ <odoo> <record id="account_report_print_journal_view" model="ir.ui.view"> - <field name="name">Sale/Purchase Journal</field> + <field name="name">Journals Audit</field> <field name="model">account.print.journal</field> <field name="inherit_id" ref="account.account_common_report_view"/> <field name="arch" type="xml"> @@ -12,15 +12,12 @@ <field name="sort_selection" widget="radio"/> <newline/> </xpath> - <xpath expr="//field[@name='journal_ids']" position="attributes"> - <attribute name="domain">[('type', 'in', ('sale', 'purchase'))]</attribute> - </xpath> </data> </field> </record> <record id="action_account_print_journal_menu" model="ir.actions.act_window"> - <field name="name">Sale/Purchase Journal</field> + <field name="name">Journals Audit</field> <field name="type">ir.actions.act_window</field> <field name="res_model">account.print.journal</field> <field name="view_type">form</field> @@ -31,7 +28,7 @@ <menuitem id="menu_print_journal" - name="Sale/Purchase Journal" + name="Journals Audit" parent="account.menu_finance_legal_statement" action="action_account_print_journal_menu" groups="account.group_account_manager,account.group_account_user" diff --git a/addons/account_budget/views/account_budget_views.xml b/addons/account_budget/views/account_budget_views.xml index 40291f6171c..cbfd2f3e062 100644 --- a/addons/account_budget/views/account_budget_views.xml +++ b/addons/account_budget/views/account_budget_views.xml @@ -97,7 +97,7 @@ <field name="crossovered_budget_line" context="{'default_date_from': date_from,'default_date_to': date_to}" colspan="4" nolabel="1" attrs="{'readonly':[('state','!=','draft')]}"> <tree string="Budget Lines"> <field name="general_budget_id"/> - <field name="analytic_account_id" groups="analytic.group_analytic_accounting"/> + <field name="analytic_account_id" groups="analytic.group_analytic_accounting" required="1"/> <field name="date_from"/> <field name="date_to"/> <field name="paid_date" groups="base.group_no_one"/> @@ -111,7 +111,7 @@ <group> <field name="general_budget_id"/> <field name="planned_amount" widget="monetary"/> - <field name="analytic_account_id" groups="analytic.group_analytic_accounting"/> + <field name="analytic_account_id" groups="analytic.group_analytic_accounting" required="1"/> </group> <group> <label for="date_from" string="Period"/> diff --git a/addons/auth_signup/views/res_config_settings_views.xml b/addons/auth_signup/views/res_config_settings_views.xml index 7027c2b0bad..0f4a5810ce8 100644 --- a/addons/auth_signup/views/res_config_settings_views.xml +++ b/addons/auth_signup/views/res_config_settings_views.xml @@ -19,11 +19,7 @@ <field name="auth_signup_uninvited" class="o_light_label" widget="radio" required="True"/> </div> <div class="content-group" attrs="{'invisible': [('auth_signup_uninvited','=','b2b')]}"> - <div class="mt16" groups="base.group_no_one" - attrs="{'invisible': [('auth_signup_template_user_id', '!=', False)]}"> - Copy access rights from <field name="auth_signup_template_user_id"/> - </div> - <div class="mt16" attrs="{'invisible': [('auth_signup_template_user_id', '=', False)]}"> + <div class="mt16"> <button type="object" name="open_template_user" string="Default Access Rights" icon="fa-arrow-right" class="btn-link"/> </div> </div> diff --git a/addons/barcodes/static/tests/barcode_tests.js b/addons/barcodes/static/tests/barcode_tests.js index 56d11af9b9a..cf77497a2a7 100644 --- a/addons/barcodes/static/tests/barcode_tests.js +++ b/addons/barcodes/static/tests/barcode_tests.js @@ -164,11 +164,12 @@ QUnit.test('do no update form twice after a command barcode scanned', function ( var delay = barcodeEvents.BarcodeEvents.max_time_between_keys_in_ms; barcodeEvents.BarcodeEvents.max_time_between_keys_in_ms = 0; - var formUpdate = FormController.prototype.update; - FormController.prototype.update = function () { - assert.step('update'); - return formUpdate.apply(this, arguments); - }; + testUtils.patch(FormController, { + update: function () { + assert.step('update'); + return this._super.apply(this, arguments); + }, + }); var form = createView({ View: FormView, @@ -206,7 +207,7 @@ QUnit.test('do no update form twice after a command barcode scanned', function ( form.destroy(); barcodeEvents.BarcodeEvents.max_time_between_keys_in_ms = delay; - FormController.prototype.update = formUpdate; + testUtils.unpatch(FormController); }); QUnit.test('widget field_float_scannable', function (assert) { diff --git a/addons/base_address_city/models/res_partner.py b/addons/base_address_city/models/res_partner.py index e92867fabb4..27b08b5a2a3 100644 --- a/addons/base_address_city/models/res_partner.py +++ b/addons/base_address_city/models/res_partner.py @@ -9,7 +9,7 @@ class Partner(models.Model): _inherit = 'res.partner' country_enforce_cities = fields.Boolean(related='country_id.enforce_cities') - city_id = fields.Many2one('res.city', string='Company') + city_id = fields.Many2one('res.city', string='City') @api.onchange('city_id') def _onchange_city_id(self): diff --git a/addons/base_import/models/base_import.py b/addons/base_import/models/base_import.py index 9699ff753aa..9d34fc6a07c 100644 --- a/addons/base_import/models/base_import.py +++ b/addons/base_import/models/base_import.py @@ -578,7 +578,7 @@ class Import(models.TransientModel): # Check that currency exists currency = self.env['res.currency'].search([('symbol', '=', split_value[currency_index].strip())]) if len(currency): - return split_value[currency_index + 1 % 2] if not negative else '-' + split_value[currency_index + 1 % 2] + return split_value[(currency_index + 1) % 2] if not negative else '-' + split_value[(currency_index + 1) % 2] # Otherwise it is not a float with a currency symbol return False diff --git a/addons/crm/data/crm_data.xml b/addons/crm/data/crm_data.xml index 5f1bfe35f86..cea07019442 100644 --- a/addons/crm/data/crm_data.xml +++ b/addons/crm/data/crm_data.xml @@ -6,6 +6,7 @@ <field name="use_leads">True</field> <field name="alias_name">sales</field> <field name="dashboard_graph_model">crm.opportunity.report</field> + <field name="company_id" eval="False"/> </record> <!--default alias for leads--> diff --git a/addons/crm/models/crm_lead.py b/addons/crm/models/crm_lead.py index fb3505b5b5e..932a8ec5bcd 100644 --- a/addons/crm/models/crm_lead.py +++ b/addons/crm/models/crm_lead.py @@ -988,7 +988,7 @@ class Lead(models.Model): result['activity']['today'] += 1 if date.today() <= date_deadline <= date.today() + timedelta(days=7): result['activity']['next_7_days'] += 1 - if date_deadline < date.today() and not opp.date_closed: + if date_deadline < date.today(): result['activity']['overdue'] += 1 # Won in Opportunities if opp.date_closed and opp.stage_id.probability == 100: diff --git a/addons/crm/security/crm_security.xml b/addons/crm/security/crm_security.xml index 83f3197a5b1..235d6854f93 100644 --- a/addons/crm/security/crm_security.xml +++ b/addons/crm/security/crm_security.xml @@ -34,6 +34,16 @@ <field name="domain_force">['|',('company_id','=',False),('company_id','child_of',[user.company_id.id])]</field> </record> + <record id="crm_rule_lead_assigned_activities" model="ir.rule"> + <field name="name">Leads with assigned activities</field> + <field ref="model_crm_lead" name="model_id"/> + <field name="domain_force">[('activity_ids.user_id','=',user.id)]</field> + <field name="groups" eval="[(4, ref('sales_team.group_sale_salesman'))]"/> + <field name="perm_read" eval="True"/> + <field name="perm_write" eval="False"/> + <field name="perm_create" eval="False"/> + <field name="perm_unlink" eval="False"/> + </record> <record id="crm_rule_all_lead" model="ir.rule"> <field name="name">All Leads</field> <field ref="model_crm_lead" name="model_id"/> diff --git a/addons/crm/views/crm_lead_views.xml b/addons/crm/views/crm_lead_views.xml index 026e1f3793c..5f12ed7119e 100644 --- a/addons/crm/views/crm_lead_views.xml +++ b/addons/crm/views/crm_lead_views.xml @@ -44,7 +44,7 @@ <form string="Leads Form"> <header> <button name="%(crm.action_crm_lead2opportunity_partner)d" string="Convert to Opportunity" type="action" - help="Convert to Opportunity" class="oe_highlight"/> + help="Convert to Opportunity" class="oe_highlight" attrs="{'invisible': [('type', '=', 'opportunity')]}"/> </header> <sheet> <div class="oe_button_box" name="button_box"> @@ -385,7 +385,7 @@ domain="[('activity_ids.user_id', '=', uid)]"/> <separator/> <filter string="Late Activities" name="activities_overdue" - domain="[('activity_ids.date_deadline', '<', context_today().strftime('%Y-%m-%d')), ('date_closed', '=', False)]" + domain="[('activity_ids.date_deadline', '<', context_today().strftime('%Y-%m-%d'))]" help="Show all records which has next action date is before today"/> <filter string="Today Activities" name="activities_today" domain="[('activity_ids.date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/> @@ -630,7 +630,7 @@ domain="[('activity_ids.user_id', '=', uid)]"/> <separator/> <filter string="Late Activities" name="activities_overdue" - domain="[('activity_ids.date_deadline', '<', context_today().strftime('%Y-%m-%d')), ('date_closed', '=', False)]" + domain="[('activity_ids.date_deadline', '<', context_today().strftime('%Y-%m-%d'))]" help="Show all opportunities for which the next action date is before today"/> <filter string="Today Activities" name="activities_today" domain="[('activity_ids.date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/> @@ -749,6 +749,34 @@ if record: </record> <!-- + 'Mark Late Activities as Done' in action dropdown + --> + <record id="action_mark_late_activities_done" model="ir.actions.server"> + <field name="name">Mark Late Activities as Done</field> + <field name="model_id" ref="model_crm_lead"/> + <field name="binding_model_id" ref="crm.model_crm_lead"/> + <field name="state">code</field> + <field name="code"> +for rec in records: + rec.activity_ids.filtered(lambda a: a.state == 'overdue').action_feedback(feedback='Closed automatically in batch') + </field> + </record> + + <!-- + 'Mark All Activities as Done' in action dropdown + --> + <record id="action_mark_activities_done" model="ir.actions.server"> + <field name="name">Mark All Activities as Done</field> + <field name="model_id" ref="model_crm_lead"/> + <field name="binding_model_id" ref="crm.model_crm_lead"/> + <field name="state">code</field> + <field name="code"> +for rec in records: + rec.activity_ids.action_feedback(feedback='Closed automatically in batch') + </field> + </record> + + <!-- Menu Actions --> <menuitem diff --git a/addons/event/models/event.py b/addons/event/models/event.py index ea87ebd5ec8..7d329e77876 100644 --- a/addons/event/models/event.py +++ b/addons/event/models/event.py @@ -44,7 +44,7 @@ class EventType(models.Model): help="It will select this default maximum value when you choose this event") auto_confirm = fields.Boolean( 'Automatically Confirm Registrations', default=True, - help="Events and registrations will automatically be confirmed" + help="Events and registrations will automatically be confirmed " "upon creation, easing the flow for simple events.") # location is_online = fields.Boolean( diff --git a/addons/event_sale/wizard/event_edit_registration.xml b/addons/event_sale/wizard/event_edit_registration.xml index 801d64d45f4..47bb964b0f2 100644 --- a/addons/event_sale/wizard/event_edit_registration.xml +++ b/addons/event_sale/wizard/event_edit_registration.xml @@ -10,7 +10,7 @@ please give details about the registrations</p> <field name="event_registration_ids"> <tree string="Registration" editable="top" create="false" delete="false"> - <field name="event_id" readonly='1'/> + <field name="event_id" readonly='1' force_save="1"/> <field name="registration_id" readonly='1'/> <field name="event_ticket_id" domain="[('event_id', '=', event_id)]" readonly='1'/> <field name="name"/> diff --git a/addons/fleet/models/fleet_vehicle.py b/addons/fleet/models/fleet_vehicle.py index 7c305ce6ef5..508e5bfe836 100644 --- a/addons/fleet/models/fleet_vehicle.py +++ b/addons/fleet/models/fleet_vehicle.py @@ -21,7 +21,7 @@ class FleetVehicle(models.Model): company_id = fields.Many2one('res.company', 'Company') license_plate = fields.Char(required=True, help='License plate number of the vehicle (i = plate number for a car)') vin_sn = fields.Char('Chassis Number', help='Unique number written on the vehicle motor (VIN/SN number)', copy=False) - driver_id = fields.Many2one('res.partner', 'Driver', help='Driver of the vehicle') + driver_id = fields.Many2one('res.partner', 'Driver', help='Driver of the vehicle', copy=False) model_id = fields.Many2one('fleet.vehicle.model', 'Model', required=True, help='Model of the vehicle') log_fuel = fields.One2many('fleet.vehicle.log.fuel', 'vehicle_id', 'Fuel Logs') log_services = fields.One2many('fleet.vehicle.log.services', 'vehicle_id', 'Services Logs') diff --git a/addons/fleet/models/fleet_vehicle_cost.py b/addons/fleet/models/fleet_vehicle_cost.py index 2a11ab84c4b..a52a94954dd 100644 --- a/addons/fleet/models/fleet_vehicle_cost.py +++ b/addons/fleet/models/fleet_vehicle_cost.py @@ -117,7 +117,7 @@ class FleetVehicleLogContract(models.Model): copy=False) notes = fields.Text('Terms and Conditions', help='Write here all supplementary information relative to this contract', copy=False) cost_generated = fields.Float('Recurring Cost Amount', - help="Costs paid at regular intervals, depending on the cost frequency." + help="Costs paid at regular intervals, depending on the cost frequency. " "If the cost frequency is set to unique, the cost will be logged at the start date") cost_frequency = fields.Selection([ ('no', 'No'), diff --git a/addons/google_account/models/google_service.py b/addons/google_account/models/google_service.py index 345030dcf6a..c547b4f6fe0 100644 --- a/addons/google_account/models/google_service.py +++ b/addons/google_account/models/google_service.py @@ -144,7 +144,7 @@ class GoogleService(models.TransientModel): if error.response.status_code == 400: # invalid grant with registry(request.session.db).cursor() as cur: self.env(cur)['res.users'].browse(self.env.uid).write({'google_%s_rtoken' % service: False}) - error_key = json.loads(error.response.json()).get("error", "nc") + error_key = error.response.json().get("error", "nc") _logger.exception("Bad google request : %s !", error_key) error_msg = _("Something went wrong during your token generation. Maybe your Authorization Code is invalid or already expired [%s]") % error_key raise self.env['res.config.settings'].get_config_warning(error_msg) diff --git a/addons/hr_attendance/static/src/js/kiosk_mode.js b/addons/hr_attendance/static/src/js/kiosk_mode.js index 6eb065739f6..d561f380c45 100644 --- a/addons/hr_attendance/static/src/js/kiosk_mode.js +++ b/addons/hr_attendance/static/src/js/kiosk_mode.js @@ -16,10 +16,10 @@ var KioskMode = Widget.extend({ var self = this; core.bus.on('barcode_scanned', this, this._onBarcodeScanned); self.session = Session; - this._rpc({ + var def = this._rpc({ model: 'res.company', method: 'search_read', - args: [[['id', '=', self.session.company_id]], ['name']], + args: [[['id', '=', this.session.company_id]], ['name']], }) .then(function (companies){ self.company_name = companies[0].name; @@ -27,7 +27,7 @@ var KioskMode = Widget.extend({ self.$el.html(QWeb.render("HrAttendanceKioskMode", {widget: self})); self.start_clock(); }); - return self._super.apply(this, arguments); + return $.when(def, this._super.apply(this, arguments)); }, _onBarcodeScanned: function(barcode) { diff --git a/addons/hr_attendance/static/src/js/my_attendances.js b/addons/hr_attendance/static/src/js/my_attendances.js index a1e11c05f40..d8f68a3c884 100644 --- a/addons/hr_attendance/static/src/js/my_attendances.js +++ b/addons/hr_attendance/static/src/js/my_attendances.js @@ -19,10 +19,10 @@ var MyAttendances = Widget.extend({ start: function () { var self = this; - this._rpc({ + var def = this._rpc({ model: 'hr.employee', method: 'search_read', - args: [[['user_id', '=', self.getSession().uid]], ['attendance_state', 'name']], + args: [[['user_id', '=', this.getSession().uid]], ['attendance_state', 'name']], }) .then(function (res) { if (_.isEmpty(res) ) { @@ -33,7 +33,7 @@ var MyAttendances = Widget.extend({ self.$el.html(QWeb.render("HrAttendanceMyMainMenu", {widget: self})); }); - return this._super.apply(this, arguments); + return $.when(def, this._super.apply(this, arguments)); }, update_attendance: function () { diff --git a/addons/hr_expense/models/account_move_line.py b/addons/hr_expense/models/account_move_line.py index 4cdc5bed0b5..8af2ef4c881 100644 --- a/addons/hr_expense/models/account_move_line.py +++ b/addons/hr_expense/models/account_move_line.py @@ -15,6 +15,8 @@ class AccountMoveLine(models.Model): res = super(AccountMoveLine, self).reconcile(writeoff_acc_id=writeoff_acc_id, writeoff_journal_id=writeoff_journal_id) account_move_ids = [l.move_id.id for l in self if float_compare(l.move_id.matched_percentage, 1, precision_digits=5) == 0] if account_move_ids: - expense_sheets = self.env['hr.expense.sheet'].search([('account_move_id', 'in', account_move_ids)]) + expense_sheets = self.env['hr.expense.sheet'].search([ + ('account_move_id', 'in', account_move_ids), ('state', '!=', 'done') + ]) expense_sheets.set_to_paid() return res diff --git a/addons/hr_expense/models/hr_expense.py b/addons/hr_expense/models/hr_expense.py index 14e88d7369a..5380365b501 100644 --- a/addons/hr_expense/models/hr_expense.py +++ b/addons/hr_expense/models/hr_expense.py @@ -445,18 +445,20 @@ class HrExpenseSheet(models.Model): @api.multi def check_consistency(self): - if any(sheet.employee_id != self[0].employee_id for sheet in self): - raise UserError(_("Expenses must belong to the same Employee.")) - - expense_lines = self.mapped('expense_line_ids') - if expense_lines and any(expense.payment_mode != expense_lines[0].payment_mode for expense in expense_lines): - raise UserError(_("Expenses must have been paid by the same entity (Company or employee)")) + for rec in self: + expense_lines = rec.expense_line_ids + if not expense_lines: + continue + if any(expense.employee_id != rec.employee_id for expense in expense_lines): + raise UserError(_("Expenses must belong to the same Employee.")) + if any(expense.payment_mode != expense_lines[0].payment_mode for expense in expense_lines): + raise UserError(_("Expenses must have been paid by the same entity (Company or employee)")) @api.model def create(self, vals): self._create_set_followers(vals) sheet = super(HrExpenseSheet, self).create(vals) - self.check_consistency() + sheet.check_consistency() return sheet @api.multi @@ -597,7 +599,7 @@ class HrExpenseSheet(models.Model): return res @api.one - @api.constrains('expense_line_ids') + @api.constrains('expense_line_ids', 'employee_id') def _check_employee(self): employee_ids = self.expense_line_ids.mapped('employee_id') if len(employee_ids) > 1 or (len(employee_ids) == 1 and employee_ids != self.employee_id): diff --git a/addons/hr_payroll/models/hr_payslip.py b/addons/hr_payroll/models/hr_payslip.py index 3aaa01910ee..fdf93d99951 100644 --- a/addons/hr_payroll/models/hr_payslip.py +++ b/addons/hr_payroll/models/hr_payslip.py @@ -311,7 +311,10 @@ class HrPayslip(models.Model): baselocaldict = {'categories': categories, 'rules': rules, 'payslip': payslips, 'worked_days': worked_days, 'inputs': inputs} #get the ids of the structures on the contracts and their parent id as well contracts = self.env['hr.contract'].browse(contract_ids) - structure_ids = contracts.get_all_structures() + if len(contracts) == 1 and payslip.struct_id: + structure_ids = list(set(payslip.struct_id._get_parent_structure().ids)) + else: + structure_ids = contracts.get_all_structures() #get the rules of the structure and thier children rule_ids = self.env['hr.payroll.structure'].browse(structure_ids).get_all_rules() #run the rules by sequence diff --git a/addons/iap/static/src/js/crash_manager.js b/addons/iap/static/src/js/crash_manager.js index 3c8d330013e..9673746907b 100644 --- a/addons/iap/static/src/js/crash_manager.js +++ b/addons/iap/static/src/js/crash_manager.js @@ -26,14 +26,18 @@ CrashManager.include({ credit: error_data.credit, } }).then(function (url) { + var content = $(QWeb.render('iap.redirect_to_odoo_credit', { + data: error_data, + })) + if (error_data.body) { + content.css('padding', 0); + } new Dialog(this, { size: 'large', title: error_data.title || _t("Insufficient Balance"), - $content: $(QWeb.render('iap.redirect_to_odoo_credit', { - data: error_data, - })).css('padding', 0), + $content: content, buttons: [ - {text: 'Buy credits at Odoo', classes : "btn-primary", click: function() { + {text: _t('Buy credits at Odoo'), classes : "btn-primary", click: function() { window.open(url, '_blank'); }, close:true}, {text: _t("Cancel"), close: true} diff --git a/addons/l10n_be_hr_payroll/data/l10n_be_hr_payroll_data.xml b/addons/l10n_be_hr_payroll/data/l10n_be_hr_payroll_data.xml index 28ed0b1dc14..4d3ca141fee 100644 --- a/addons/l10n_be_hr_payroll/data/l10n_be_hr_payroll_data.xml +++ b/addons/l10n_be_hr_payroll/data/l10n_be_hr_payroll_data.xml @@ -373,7 +373,7 @@ elif salary <= 2461.27: <field name="code">Red.Spouse.Net</field> <field name="sequence">148</field> <field name="condition_select">python</field> - <field name="condition_python">result = (employee.marital == 'married' and employee.spouse_net_revenue <= 221.0) and (categories.PP + categories.PPRed + categories.FamRed + categories.ChA < 0.0)</field> + <field name="condition_python">result = (employee.marital == 'married' and employee.spouse_fiscal_status=='with income' and employee.spouse_net_revenue <= 221.0) and (categories.PP + categories.PPRed + categories.FamRed + categories.ChA < 0.0)</field> <field name="amount_select">code</field> <field name="amount_python_compute">result = min(110.50, - (categories.PP + categories.PPRed + categories.FamRed + categories.ChA))</field> </record> @@ -384,7 +384,7 @@ elif salary <= 2461.27: <field name="code">Red.Spouse.Oth.Net</field> <field name="sequence">149</field> <field name="condition_select">python</field> - <field name="condition_python">result = (employee.marital == 'married' and not employee.spouse_net_revenue and employee.spouse_other_net_revenue <= 440.0) and (categories.PP + categories.PPRed + categories.FamRed + categories.ChA < 0.0)</field> + <field name="condition_python">result = (employee.marital == 'married' and employee.spouse_fiscal_status=='with income' and not employee.spouse_net_revenue and employee.spouse_other_net_revenue <= 440.0) and (categories.PP + categories.PPRed + categories.FamRed + categories.ChA < 0.0)</field> <field name="amount_select">code</field> <field name="amount_python_compute">result = min(220.0, - (categories.PP + categories.PPRed + categories.FamRed + categories.ChA))</field> </record> diff --git a/addons/l10n_be_hr_payroll/models/l10n_be_hr_payroll.py b/addons/l10n_be_hr_payroll/models/l10n_be_hr_payroll.py index e6d11322c6e..1b9835b1624 100644 --- a/addons/l10n_be_hr_payroll/models/l10n_be_hr_payroll.py +++ b/addons/l10n_be_hr_payroll/models/l10n_be_hr_payroll.py @@ -215,23 +215,23 @@ class HrEmployee(models.Model): spouse_fiscal_status = fields.Selection([ ('without income', 'Without Income'), ('with income', 'With Income') - ], string='Tax status for spouse') - disabled = fields.Boolean(string="Disabled", help="If the employee is declared disabled by law") - disabled_spouse_bool = fields.Boolean(string='Disabled Spouse', help='if recipient spouse is declared disabled by law') - disabled_children_bool = fields.Boolean(string='Disabled Children', help='if recipient children is/are declared disabled by law') - resident_bool = fields.Boolean(string='Nonresident', help='if recipient lives in a foreign country') - disabled_children_number = fields.Integer('Number of disabled children') - dependent_children = fields.Integer(compute='_compute_dependent_children', string='Considered number of dependent children') - other_dependent_people = fields.Boolean(string="Other Dependent People", help="If other people are dependent on the employee") - other_senior_dependent = fields.Integer('# seniors (>=65)', help="Number of seniors dependent on the employee, including the disabled ones") - other_disabled_senior_dependent = fields.Integer('# disabled seniors (>=65)') - other_juniors_dependent = fields.Integer('# people (<65)', help="Number of juniors dependent on the employee, including the disabled ones") - other_disabled_juniors_dependent = fields.Integer('# disabled people (<65)') - dependent_seniors = fields.Integer(compute='_compute_dependent_people', string="Considered number of dependent seniors") - dependent_juniors = fields.Integer(compute='_compute_dependent_people', string="Considered number of dependent juniors") - spouse_net_revenue = fields.Float(string="Spouse Net Revenue", help="Own professional income, other than pensions, annuities or similar income") + ], string='Tax status for spouse', groups="hr.group_hr_user") + disabled = fields.Boolean(string="Disabled", help="If the employee is declared disabled by law", groups="hr.group_hr_user") + disabled_spouse_bool = fields.Boolean(string='Disabled Spouse', help='if recipient spouse is declared disabled by law', groups="hr.group_hr_user") + disabled_children_bool = fields.Boolean(string='Disabled Children', help='if recipient children is/are declared disabled by law', groups="hr.group_hr_user") + resident_bool = fields.Boolean(string='Nonresident', help='if recipient lives in a foreign country', groups="hr.group_hr_user") + disabled_children_number = fields.Integer('Number of disabled children', groups="hr.group_hr_user") + dependent_children = fields.Integer(compute='_compute_dependent_children', string='Considered number of dependent children', groups="hr.group_hr_user") + other_dependent_people = fields.Boolean(string="Other Dependent People", help="If other people are dependent on the employee", groups="hr.group_hr_user") + other_senior_dependent = fields.Integer('# seniors (>=65)', help="Number of seniors dependent on the employee, including the disabled ones", groups="hr.group_hr_user") + other_disabled_senior_dependent = fields.Integer('# disabled seniors (>=65)', groups="hr.group_hr_user") + other_juniors_dependent = fields.Integer('# people (<65)', help="Number of juniors dependent on the employee, including the disabled ones", groups="hr.group_hr_user") + other_disabled_juniors_dependent = fields.Integer('# disabled people (<65)', groups="hr.group_hr_user") + dependent_seniors = fields.Integer(compute='_compute_dependent_people', string="Considered number of dependent seniors", groups="hr.group_hr_user") + dependent_juniors = fields.Integer(compute='_compute_dependent_people', string="Considered number of dependent juniors", groups="hr.group_hr_user") + spouse_net_revenue = fields.Float(string="Spouse Net Revenue", help="Own professional income, other than pensions, annuities or similar income", groups="hr.group_hr_user") spouse_other_net_revenue = fields.Float(string="Spouse Other Net Revenue", - help='Own professional income which is exclusively composed of pensions, annuities or similar income') + help='Own professional income which is exclusively composed of pensions, annuities or similar income', groups="hr.group_hr_user") @api.constrains('spouse_fiscal_status', 'spouse_net_revenue', 'spouse_other_net_revenue') def _check_spouse_revenue(self): diff --git a/addons/l10n_be_hr_payroll/views/l10n_be_hr_payroll_view.xml b/addons/l10n_be_hr_payroll/views/l10n_be_hr_payroll_view.xml index 4e7d429f102..37fa58ae2b7 100644 --- a/addons/l10n_be_hr_payroll/views/l10n_be_hr_payroll_view.xml +++ b/addons/l10n_be_hr_payroll/views/l10n_be_hr_payroll_view.xml @@ -141,7 +141,7 @@ <field name="disabled_children_bool"/> <field name="disabled_children_number" attrs="{'invisible':[('disabled_children_bool','!=',True)]}" colspan="1" help="number of dependent children declared as disabled"/> <field name="dependent_children" invisible="1"/> - <field name="other_dependent_people"/> + <field name="other_dependent_people" groups="hr.group_hr_user"/> <field name="other_senior_dependent" attrs="{'invisible':[('other_dependent_people','!=',True)]}"/> <field name="other_disabled_senior_dependent" attrs="{'invisible':[('other_dependent_people','!=',True)]}"/> <field name="other_juniors_dependent" attrs="{'invisible':[('other_dependent_people','!=',True)]}"/> diff --git a/addons/l10n_be_hr_payroll_fleet/models/fleet.py b/addons/l10n_be_hr_payroll_fleet/models/fleet.py index b0ba4206ec4..2580fae49c1 100644 --- a/addons/l10n_be_hr_payroll_fleet/models/fleet.py +++ b/addons/l10n_be_hr_payroll_fleet/models/fleet.py @@ -57,11 +57,12 @@ class FleetVehicle(models.Model): for car in self: car.atn = car._get_car_atn(car.acquisition_date, car.car_value, car.fuel_type, car.co2) - @api.depends('model_id', 'license_plate', 'log_contracts', 'total_depreciated_cost', 'acquisition_date') + @api.depends('model_id', 'license_plate', 'log_contracts', 'acquisition_date', + 'co2_fee', 'log_contracts', 'log_contracts.state', 'log_contracts.recurring_cost_amount_depreciated') def _compute_vehicle_name(self): super(FleetVehicle, self)._compute_vehicle_name() for vehicle in self: - acquisition_date = self._get_acquisition_date() + acquisition_date = vehicle._get_acquisition_date() vehicle.name += u" \u2022 " + str(round(vehicle.total_depreciated_cost, 2)) + u" \u2022 " + acquisition_date def _get_acquisition_date(self): @@ -138,14 +139,14 @@ class FleetVehicleModel(models.Model): model.default_total_depreciated_cost = model.co2_fee + model.default_recurring_cost_amount_depreciated @api.multi - @api.depends('name', 'brand_id') + @api.depends('name', 'brand_id', 'default_total_depreciated_cost') def name_get(self): res = super(FleetVehicleModel, self).name_get() new_res = [] for res_item in res: model = self.browse(res_item[0]) if model.default_total_depreciated_cost != 0.0: - new_res.append((res_item[0], res_item[1] + u" \u2022 " + str(model.default_total_depreciated_cost))) + new_res.append((res_item[0], res_item[1] + u" \u2022 " + str(round(model.default_total_depreciated_cost, 2)))) else: new_res.append(res_item) return new_res diff --git a/addons/l10n_be_hr_payroll_fleet/models/hr_contract.py b/addons/l10n_be_hr_payroll_fleet/models/hr_contract.py index 6e20c4570d0..7f802498934 100644 --- a/addons/l10n_be_hr_payroll_fleet/models/hr_contract.py +++ b/addons/l10n_be_hr_payroll_fleet/models/hr_contract.py @@ -11,8 +11,8 @@ class HrContract(models.Model): domain=lambda self: self._get_available_cars_domain(), default=lambda self: self.env['fleet.vehicle'].search([('driver_id', '=', self.employee_id.address_home_id.id)], limit=1), help="Employee's company car.") - car_atn = fields.Float(compute='_compute_car_atn_and_costs', string='ATN Company Car') - company_car_total_depreciated_cost = fields.Float(compute='_compute_car_atn_and_costs') + car_atn = fields.Float(compute='_compute_car_atn_and_costs', string='ATN Company Car', store=True) + company_car_total_depreciated_cost = fields.Float(compute='_compute_car_atn_and_costs', store=True) available_cars_amount = fields.Integer(compute='_compute_available_cars_amount', string='Number of available cars') new_car = fields.Boolean('Request a new car') new_car_model_id = fields.Many2one('fleet.vehicle.model', string="Model", domain=lambda self: self._get_possible_model_domain()) diff --git a/addons/l10n_be_hr_payroll_fleet/views/hr_contract_views.xml b/addons/l10n_be_hr_payroll_fleet/views/hr_contract_views.xml index 4075178b573..c6a7ad33abd 100644 --- a/addons/l10n_be_hr_payroll_fleet/views/hr_contract_views.xml +++ b/addons/l10n_be_hr_payroll_fleet/views/hr_contract_views.xml @@ -12,7 +12,7 @@ <field name="car_id" nolabel="1" attrs="{'invisible': [('new_car', '=', True)]}"/> <field name="available_cars_amount" invisible="1"/> </div> - <field name="new_car_model_id" attrs="{'invisible': [('new_car', '=', False)]}"/> + <field name="new_car_model_id" attrs="{'invisible': ['|', ('transport_mode', '!=', 'company_car'), ('new_car', '=', False)]}"/> <field name="max_unused_cars" invisible="1"/> </xpath> <xpath expr="//field[@name='car_atn']" position="after"> diff --git a/addons/l10n_ch/__manifest__.py b/addons/l10n_ch/__manifest__.py index 7c0a0c37e7b..f833530cb1c 100644 --- a/addons/l10n_ch/__manifest__.py +++ b/addons/l10n_ch/__manifest__.py @@ -15,7 +15,7 @@ This module defines a chart of account for Switzerland (Swiss PME/KMU 2015), tax An ISR will be generated if you specify the information it needs : - The bank account you expect to be paid on must be set, and have a valid postal reference. - Your invoice must have been set assigned a bank account to receive its payment - (this can be done manually, but a default value is automatically set if you have defined a bank account). + (this can be done manually, but a default value is automatically set if you have defined a bank account). - You must have set the postal references of your bank. - Your invoice must be in EUR or CHF (as ISRs do not accept other currencies) diff --git a/addons/l10n_ch/data/account_data.xml b/addons/l10n_ch/data/account_data.xml index cbc81a7eb00..052631fdd7e 100644 --- a/addons/l10n_ch/data/account_data.xml +++ b/addons/l10n_ch/data/account_data.xml @@ -9,9 +9,15 @@ <record id="tax_group_tva_25" model="account.tax.group"> <field name="name">TVA 2.5%</field> </record> + <record id="tax_group_tva_37" model="account.tax.group"> + <field name="name">TVA 3.7%</field> + </record> <record id="tax_group_tva_38" model="account.tax.group"> <field name="name">TVA 3.8%</field> </record> + <record id="tax_group_tva_77" model="account.tax.group"> + <field name="name">TVA 7.7%</field> + </record> <record id="tax_group_tva_8" model="account.tax.group"> <field name="name">TVA 8%</field> </record> diff --git a/addons/l10n_ch/data/account_fiscal_position_data.xml b/addons/l10n_ch/data/account_fiscal_position_data.xml index 869ee872407..f2fd5cc7907 100644 --- a/addons/l10n_ch/data/account_fiscal_position_data.xml +++ b/addons/l10n_ch/data/account_fiscal_position_data.xml @@ -19,6 +19,17 @@ <field name="tax_dest_id" ref="vat_O_import" /> </record> + <record id="fiscal_position_tax_template_5" model="account.fiscal.position.tax.template"> + <field name="position_id" ref="fiscal_position_template_import" /> + <field name="tax_src_id" ref="vat_37_purchase" /> + <field name="tax_dest_id" ref="vat_O_import" /> + </record> + <record id="fiscal_position_tax_template_6" model="account.fiscal.position.tax.template"> + <field name="position_id" ref="fiscal_position_template_import" /> + <field name="tax_src_id" ref="vat_37_invest" /> + <field name="tax_dest_id" ref="vat_O_import" /> + </record> + <record id="fiscal_position_tax_template_7" model="account.fiscal.position.tax.template"> <field name="position_id" ref="fiscal_position_template_import" /> <field name="tax_src_id" ref="vat_38_purchase" /> @@ -30,6 +41,17 @@ <field name="tax_dest_id" ref="vat_O_import" /> </record> + <record id="fiscal_position_tax_template_9" model="account.fiscal.position.tax.template"> + <field name="position_id" ref="fiscal_position_template_import" /> + <field name="tax_src_id" ref="vat_77_purchase" /> + <field name="tax_dest_id" ref="vat_O_import" /> + </record> + <record id="fiscal_position_tax_template_10" model="account.fiscal.position.tax.template"> + <field name="position_id" ref="fiscal_position_template_import" /> + <field name="tax_src_id" ref="vat_77_invest" /> + <field name="tax_dest_id" ref="vat_O_import" /> + </record> + <record id="fiscal_position_tax_template_11" model="account.fiscal.position.tax.template"> <field name="position_id" ref="fiscal_position_template_import" /> <field name="tax_src_id" ref="vat_80_purchase" /> @@ -47,12 +69,24 @@ <field name="tax_dest_id" ref="vat_XO" /> </record> + <record id="fiscal_position_tax_template_15" model="account.fiscal.position.tax.template"> + <field name="position_id" ref="fiscal_position_template_import" /> + <field name="tax_src_id" ref="vat_37" /> + <field name="tax_dest_id" ref="vat_XO" /> + </record> + <record id="fiscal_position_tax_template_16" model="account.fiscal.position.tax.template"> <field name="position_id" ref="fiscal_position_template_import" /> <field name="tax_src_id" ref="vat_38" /> <field name="tax_dest_id" ref="vat_XO" /> </record> + <record id="fiscal_position_tax_template_17" model="account.fiscal.position.tax.template"> + <field name="position_id" ref="fiscal_position_template_import" /> + <field name="tax_src_id" ref="vat_77" /> + <field name="tax_dest_id" ref="vat_XO" /> + </record> + <record id="fiscal_position_tax_template_18" model="account.fiscal.position.tax.template"> <field name="position_id" ref="fiscal_position_template_import" /> <field name="tax_src_id" ref="vat_80" /> diff --git a/addons/l10n_ch/data/account_vat2011_data.xml b/addons/l10n_ch/data/account_vat2011_data.xml index c7ea02fa951..ef45ee02a01 100644 --- a/addons/l10n_ch/data/account_vat2011_data.xml +++ b/addons/l10n_ch/data/account_vat2011_data.xml @@ -32,6 +32,10 @@ <field name="name">Switzerland VAT Form: grid 301 base</field> <field name="applicability">taxes</field> </record> + <record id="vat_tag_302_a" model="account.account.tag"> + <field name="name">Switzerland VAT Form: grid 302 base</field> + <field name="applicability">taxes</field> + </record> <record id="vat_tag_311_a" model="account.account.tag"> <field name="name">Switzerland VAT Form: grid 311 base</field> <field name="applicability">taxes</field> @@ -40,6 +44,10 @@ <field name="name">Switzerland VAT Form: grid 341 base</field> <field name="applicability">taxes</field> </record> + <record id="vat_tag_342_a" model="account.account.tag"> + <field name="name">Switzerland VAT Form: grid 342 base</field> + <field name="applicability">taxes</field> + </record> <record id="vat_tag_381_a" model="account.account.tag"> <field name="name">Switzerland VAT Form: grid 381 base</field> <field name="applicability">taxes</field> @@ -48,6 +56,10 @@ <field name="name">Switzerland VAT Form: grid 301 tax</field> <field name="applicability">taxes</field> </record> + <record id="vat_tag_302_b" model="account.account.tag"> + <field name="name">Switzerland VAT Form: grid 302 tax</field> + <field name="applicability">taxes</field> + </record> <record id="vat_tag_311_b" model="account.account.tag"> <field name="name">Switzerland VAT Form: grid 311 tax</field> <field name="applicability">taxes</field> @@ -56,6 +68,10 @@ <field name="name">Switzerland VAT Form: grid 341 tax</field> <field name="applicability">taxes</field> </record> + <record id="vat_tag_342_b" model="account.account.tag"> + <field name="name">Switzerland VAT Form: grid 342 tax</field> + <field name="applicability">taxes</field> + </record> <record id="vat_tag_381_b" model="account.account.tag"> <field name="name">Switzerland VAT Form: grid 381 tax</field> <field name="applicability">taxes</field> @@ -166,6 +182,81 @@ <field name="tax_group_id" ref="tax_group_tva_25"/> </record> + <record model="account.tax.template" id="vat_37"> + <field name="name">TVA due a 3.7% (TS)</field> + <field name="description">3.7%</field> + <field name="amount" eval="3.7"/> + <field name="amount_type">percent</field> + <field name="chart_template_id" ref="l10nch_chart_template"/> + <field name="type_tax_use">sale</field> + <field name="refund_account_id" ref="ch_coa_2200"/> + <field name="account_id" ref="ch_coa_2200"/> + <field name="tag_ids" eval="[(6,0,[ref('vat_tag_342_a'), ref('vat_tag_342_b')])]"/> + <field name="tax_group_id" ref="tax_group_tva_37"/> + </record> + <record model="account.tax.template" id="vat_37_incl"> + <field name="name">TVA due à 3.7% (Incl. TS)</field> + <field name="description">3.7% Incl.</field> + <field name="price_include" eval="1"/> + <field name="amount" eval="3.7"/> + <field name="amount_type">percent</field> + <field name="chart_template_id" ref="l10nch_chart_template"/> + <field name="type_tax_use">sale</field> + <field name="refund_account_id" ref="ch_coa_2200"/> + <field name="account_id" ref="ch_coa_2200"/> + <field name="tag_ids" eval="[(6,0,[ref('vat_tag_342_a'), ref('vat_tag_342_b')])]"/> + <field name="tax_group_id" ref="tax_group_tva_37"/> + </record> + <record model="account.tax.template" id="vat_37_purchase"> + <field name="name">TVA 3.7% sur achat B&S (TS)</field> + <field name="description">3.7% achat</field> + <field name="amount" eval="3.7"/> + <field name="amount_type">percent</field> + <field name="chart_template_id" ref="l10nch_chart_template"/> + <field name="type_tax_use">purchase</field> + <field name="refund_account_id" ref="ch_coa_1170"/> + <field name="account_id" ref="ch_coa_1170"/> + <field name="tag_ids" eval="[(6,0,[ref('vat_tag_400')])]"/> + <field name="tax_group_id" ref="tax_group_tva_37"/> + </record> + <record model="account.tax.template" id="vat_37_purchase_incl"> + <field name="name">TVA 3.7% sur achat B&S (Incl. TS)</field> + <field name="description">3.7% achat Incl.</field> + <field name="price_include" eval="1"/> + <field name="amount" eval="3.7"/> + <field name="amount_type">percent</field> + <field name="chart_template_id" ref="l10nch_chart_template"/> + <field name="type_tax_use">purchase</field> + <field name="refund_account_id" ref="ch_coa_1170"/> + <field name="account_id" ref="ch_coa_1170"/> + <field name="tag_ids" eval="[(6,0,[ref('vat_tag_400')])]"/> + <field name="tax_group_id" ref="tax_group_tva_37"/> + </record> + <record model="account.tax.template" id="vat_37_invest"> + <field name="name">TVA 3.7% sur invest. et autres ch. (TS)</field> + <field name="description">3.7% invest</field> + <field name="amount" eval="3.7"/> + <field name="amount_type">percent</field> + <field name="chart_template_id" ref="l10nch_chart_template"/> + <field name="type_tax_use">purchase</field> + <field name="refund_account_id" ref="ch_coa_1171"/> + <field name="account_id" ref="ch_coa_1171"/> + <field name="tag_ids" eval="[(6,0,[ref('vat_tag_405')])]"/> + <field name="tax_group_id" ref="tax_group_tva_37"/> + </record> + <record model="account.tax.template" id="vat_37_invest_incl"> + <field name="name">TVA 3.7% sur invest. et autres ch. (Incl. TS)</field> + <field name="description">3.7% invest Incl.</field> + <field name="price_include" eval="1"/> + <field name="amount" eval="3.7"/> + <field name="amount_type">percent</field> + <field name="chart_template_id" ref="l10nch_chart_template"/> + <field name="type_tax_use">purchase</field> + <field name="refund_account_id" ref="ch_coa_1171"/> + <field name="account_id" ref="ch_coa_1171"/> + <field name="tag_ids" eval="[(6,0,[ref('vat_tag_405')])]"/> + <field name="tax_group_id" ref="tax_group_tva_37"/> + </record> <record model="account.tax.template" id="vat_38"> <field name="name">TVA due a 3.8% (TS)</field> <field name="description">3.8%</field> @@ -241,6 +332,85 @@ <field name="tag_ids" eval="[(6,0,[ref('vat_tag_405')])]"/> <field name="tax_group_id" ref="tax_group_tva_38"/> </record> + <record model="account.tax.template" id="vat_77"> + <field name="name">TVA due a 7.7% (TN)</field> + <field name="description">7.7%</field> + <field name="amount" eval="7.7"/> + <field name="sequence" eval="0"/> + <field name="amount_type">percent</field> + <field name="chart_template_id" ref="l10nch_chart_template"/> + <field name="type_tax_use">sale</field> + <field name="refund_account_id" ref="ch_coa_2200"/> + <field name="account_id" ref="ch_coa_2200"/> + <field name="tag_ids" eval="[(6,0,[ref('vat_tag_302_a'), ref('vat_tag_302_b')])]"/> + <field name="tax_group_id" ref="tax_group_tva_77"/> + </record> + <record model="account.tax.template" id="vat_77_incl"> + <field name="name">TVA due à 7.7% (Incl. TN)</field> + <field name="description">7.7% Incl.</field> + <field name="price_include" eval="1"/> + <field name="amount" eval="7.7"/> + <field name="sequence" eval="0"/> + <field name="amount_type">percent</field> + <field name="chart_template_id" ref="l10nch_chart_template"/> + <field name="type_tax_use">sale</field> + <field name="refund_account_id" ref="ch_coa_2200"/> + <field name="account_id" ref="ch_coa_2200"/> + <field name="tag_ids" eval="[(6,0,[ref('vat_tag_302_a'), ref('vat_tag_302_b')])]"/> + <field name="tax_group_id" ref="tax_group_tva_77"/> + </record> + <record model="account.tax.template" id="vat_77_purchase"> + <field name="name">TVA 7.7% sur achat B&S (TN)</field> + <field name="description">7.7% achat</field> + <field name="amount" eval="7.7"/> + <field name="amount_type">percent</field> + <field name="sequence" eval="0"/> + <field name="chart_template_id" ref="l10nch_chart_template"/> + <field name="type_tax_use">purchase</field> + <field name="refund_account_id" ref="ch_coa_1170"/> + <field name="account_id" ref="ch_coa_1170"/> + <field name="tag_ids" eval="[(6,0,[ref('vat_tag_400')])]"/> + <field name="tax_group_id" ref="tax_group_tva_77"/> + </record> + <record model="account.tax.template" id="vat_77_purchase_incl"> + <field name="name">TVA 7.7% sur achat B&S (Incl. TN)</field> + <field name="description">7.7% achat Incl.</field> + <field name="price_include" eval="1"/> + <field name="amount" eval="7.7"/> + <field name="amount_type">percent</field> + <field name="sequence" eval="0"/> + <field name="chart_template_id" ref="l10nch_chart_template"/> + <field name="type_tax_use">purchase</field> + <field name="refund_account_id" ref="ch_coa_1170"/> + <field name="account_id" ref="ch_coa_1170"/> + <field name="tag_ids" eval="[(6,0,[ref('vat_tag_400')])]"/> + <field name="tax_group_id" ref="tax_group_tva_77"/> + </record> + <record model="account.tax.template" id="vat_77_invest"> + <field name="name">TVA 7.7% sur invest. et autres ch. (TN)</field> + <field name="description">7.7% invest.</field> + <field name="amount" eval="7.7"/> + <field name="amount_type">percent</field> + <field name="chart_template_id" ref="l10nch_chart_template"/> + <field name="type_tax_use">purchase</field> + <field name="refund_account_id" ref="ch_coa_1171"/> + <field name="account_id" ref="ch_coa_1171"/> + <field name="tag_ids" eval="[(6,0,[ref('vat_tag_405')])]"/> + <field name="tax_group_id" ref="tax_group_tva_77"/> + </record> + <record model="account.tax.template" id="vat_77_invest_incl"> + <field name="name">TVA 7.7% sur invest. et autres ch. (Incl. TN)</field> + <field name="description">7.7% invest. Incl.</field> + <field name="price_include" eval="1"/> + <field name="amount" eval="7.7"/> + <field name="amount_type">percent</field> + <field name="chart_template_id" ref="l10nch_chart_template"/> + <field name="type_tax_use">purchase</field> + <field name="refund_account_id" ref="ch_coa_1171"/> + <field name="account_id" ref="ch_coa_1171"/> + <field name="tag_ids" eval="[(6,0,[ref('vat_tag_405')])]"/> + <field name="tax_group_id" ref="tax_group_tva_77"/> + </record> <record model="account.tax.template" id="vat_80"> <field name="name">TVA due a 8.0% (TN)</field> <field name="description">8.0%</field> diff --git a/addons/l10n_ch/models/account_invoice.py b/addons/l10n_ch/models/account_invoice.py index 8912b763db7..928e5887415 100644 --- a/addons/l10n_ch/models/account_invoice.py +++ b/addons/l10n_ch/models/account_invoice.py @@ -59,10 +59,11 @@ class AccountInvoice(models.Model): """ The ISR reference number is 27 characters long. The first 12 of them contain the postal account number of this ISR's issuer, removing the zeros at the beginning and filling the empty places with zeros on the right if it is - too short. The 15 other characters contain an internal reference identifying + too short. The next 14 characters contain an internal reference identifying the invoice. For this, we use the invoice sequence number, removing each of its non-digit characters, and pad the unused spaces on the left of - this number with zeros. + this number with zeros. The last character of the ISR number is the result + of a recursive modulo 10 on its first 26 characters. """ def _space_isr_number(isr_number): to_treat = isr_number @@ -81,9 +82,9 @@ class AccountInvoice(models.Model): invoice_ref = re.sub('[^\d]', '', record.number) #We only keep the last digits of the sequence number if it is too long invoice_ref = invoice_ref[-l10n_ch_ISR_NUMBER_ISSUER_LENGTH:] - internal_ref = invoice_ref.zfill(l10n_ch_ISR_NUMBER_LENGTH - l10n_ch_ISR_NUMBER_ISSUER_LENGTH) + internal_ref = invoice_ref.zfill(l10n_ch_ISR_NUMBER_LENGTH - l10n_ch_ISR_NUMBER_ISSUER_LENGTH - 1) # -1 for mod10r check character - record.l10n_ch_isr_number = invoice_issuer_ref + internal_ref + record.l10n_ch_isr_number = mod10r(invoice_issuer_ref + internal_ref) record.l10n_ch_isr_number_spaced = _space_isr_number(record.l10n_ch_isr_number) @api.depends('currency_id.name', 'amount_total', 'partner_bank_id.bank_id', 'number', 'partner_bank_id.l10n_ch_postal', 'partner_bank_id.bank_id.l10n_ch_postal_eur', 'partner_bank_id.bank_id.l10n_ch_postal_chf') diff --git a/addons/l10n_fr_certification/__init__.py b/addons/l10n_fr_certification/__init__.py new file mode 100644 index 00000000000..75a45103471 --- /dev/null +++ b/addons/l10n_fr_certification/__init__.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models +from odoo import api, SUPERUSER_ID +from .models.res_company import UNALTERABLE_COUNTRIES + + +def _setup_inalterability(cr, registry): + env = api.Environment(cr, SUPERUSER_ID, {}) + # enable ping for this module + env['publisher_warranty.contract'].update_notification(cron_mode=True) + + fr_companies = env['res.company'].search([('partner_id.country_id.code', 'in', UNALTERABLE_COUNTRIES)]) + if fr_companies: + # create the securisation sequence per company + fr_companies._create_secure_sequence(['l10n_fr_secure_sequence_id']) + + #reset the update_posted field on journals + journals = env['account.journal'].search([('company_id', 'in', fr_companies.ids)]) + journals.write({'update_posted': False}) diff --git a/addons/l10n_fr_certification/__manifest__.py b/addons/l10n_fr_certification/__manifest__.py new file mode 100644 index 00000000000..426eabf66ef --- /dev/null +++ b/addons/l10n_fr_certification/__manifest__.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'France - VAT Anti-Fraud Certification (CGI 286 I-3 bis)', + 'version': '1.0', + 'category': 'Localization', + 'description': """ +This add-on brings the technical requirements of the French regulation CGI art. 286, I. 3° bis that stipulates certain criteria concerning the inalterability, security, storage and archiving of data related to sales to private individuals (B2C). +----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + +The module adds following features: + + Inalterability: deactivation of all the ways to cancel or modify key data, invoices and journal entries + + Security: chaining algorithm to verify the inalterability + + Storage: automatic sales closings with computation of both period and cumulative totals (daily, monthly, annually) + + Access to download the mandatory Certificate of Conformity delivered by Odoo SA (only for Odoo Enterprise users) +""", + 'depends': ['l10n_fr'], + 'installable': True, + 'auto_install': False, + 'application': False, + 'data': [ + 'data/account_move.xml', + 'views/res_config.xml', + ], + 'post_init_hook': '_setup_inalterability', +} diff --git a/addons/l10n_fr_certification/data/account_move.xml b/addons/l10n_fr_certification/data/account_move.xml new file mode 100644 index 00000000000..2794e6364f9 --- /dev/null +++ b/addons/l10n_fr_certification/data/account_move.xml @@ -0,0 +1,25 @@ +<odoo> + <record model="ir.actions.server" id="action_check_hash_integrity"> + <field name="name">Data Inalterability Check</field> + <field name="model_id" ref="account.model_account_move"/> + <field name="type">ir.actions.server</field> + <field name="code"> + action = env['account.move']._check_hash_integrity(env.user.company_id.id) + </field> + </record> + + <record model="ir.actions.server" id="odoo_french_certification"> + <field name="name">Get the certification now!</field> + <field name="type">ir.actions.server</field> + <field name="model_id" ref="account.model_res_company"/> + <field name="code"> +db_uuid = env['ir.config_parameter'].sudo().get_param('database.uuid') +action = {"type": "ir.actions.act_url", + "url": "https://www.odoo.com/my/contract/french-certification/db/" + db_uuid, + "target": "new",} + </field> + </record> + + <menuitem action="action_check_hash_integrity" id="menu_check_move_integrity_reporting" parent="l10n_fr.account_reports_fr_statements_menu" sequence="90"/> + +</odoo> diff --git a/addons/l10n_fr_certification/models/__init__.py b/addons/l10n_fr_certification/models/__init__.py new file mode 100644 index 00000000000..6950f8f5fc9 --- /dev/null +++ b/addons/l10n_fr_certification/models/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import res_company +from . import account diff --git a/addons/l10n_fr_certification/models/account.py b/addons/l10n_fr_certification/models/account.py new file mode 100644 index 00000000000..ff7acf2678e --- /dev/null +++ b/addons/l10n_fr_certification/models/account.py @@ -0,0 +1,197 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from hashlib import sha256 +from json import dumps + +from odoo import models, api, fields +from odoo.tools.translate import _ +from odoo.exceptions import UserError + + +#forbidden fields +MOVE_FIELDS = ['date', 'journal_id', 'company_id'] +LINE_FIELDS = ['debit', 'credit', 'account_id', 'partner_id'] + + +class AccountMove(models.Model): + _inherit = "account.move" + + # TO DO in master : refactor hashing algo to go into a mixin + + l10n_fr_secure_sequence_number = fields.Integer(string="Inalteralbility No Gap Sequence #", readonly=True, copy=False) + l10n_fr_hash = fields.Char(string="Inalterability Hash", readonly=True, copy=False) + l10n_fr_string_to_hash = fields.Char(compute='_compute_string_to_hash', readonly=True, store=False) + + def _get_new_hash(self, secure_seq_number): + """ Returns the hash to write on journal entries when they get posted""" + self.ensure_one() + #get the only one exact previous move in the securisation sequence + prev_move = self.search([('state', '=', 'posted'), + ('company_id', '=', self.company_id.id), + ('l10n_fr_secure_sequence_number', '!=', 0), + ('l10n_fr_secure_sequence_number', '=', int(secure_seq_number) - 1)]) + if prev_move and len(prev_move) != 1: + raise UserError( + _('An error occured when computing the inalterability. Impossible to get the unique previous posted journal entry.')) + + #build and return the hash + return self._compute_hash(prev_move.l10n_fr_hash if prev_move else '') + + def _compute_hash(self, previous_hash): + """ Computes the hash of the browse_record given as self, based on the hash + of the previous record in the company's securisation sequence given as parameter""" + self.ensure_one() + hash_string = sha256(previous_hash + self.l10n_fr_string_to_hash) + return hash_string.hexdigest() + + def _compute_string_to_hash(self): + def _getattrstring(obj, field_str): + field_value = obj[field_str] + if obj._fields[field_str].type == 'many2one': + field_value = field_value.id + return str(field_value) + + for move in self: + values = {} + for field in MOVE_FIELDS: + values[field] = _getattrstring(move, field) + + for line in move.line_ids: + for field in LINE_FIELDS: + k = 'line_%d_%s' % (line.id, field) + values[k] = _getattrstring(line, field) + #make the json serialization canonical + # (https://tools.ietf.org/html/draft-staykov-hu-json-canonical-form-00) + move.l10n_fr_string_to_hash = dumps(values, sort_keys=True, encoding="utf-8", + ensure_ascii=True, indent=None, + separators=(',',':')) + + @api.multi + def write(self, vals): + has_been_posted = False + for move in self: + if move.company_id._is_accounting_unalterable(): + # write the hash and the secure_sequence_number when posting an account.move + if vals.get('state') == 'posted': + has_been_posted = True + + # restrict the operation in case we are trying to write a forbidden field + if (move.state == "posted" and set(vals).intersection(MOVE_FIELDS)): + raise UserError(_("According to the French law, you cannot modify a journal entry in order for its posted data to be updated or deleted. Unauthorized field: %s.") % ', '.join(MOVE_FIELDS)) + # restrict the operation in case we are trying to overwrite existing hash + if (move.l10n_fr_hash and 'l10n_fr_hash' in vals) or (move.l10n_fr_secure_sequence_number and 'l10n_fr_secure_sequence_number' in vals): + raise UserError(_('You cannot overwrite the values ensuring the inalterability of the accounting.')) + res = super(AccountMove, self).write(vals) + # write the hash and the secure_sequence_number when posting an account.move + if has_been_posted: + for move in self.filtered(lambda m: m.company_id._is_accounting_unalterable() and + not (m.l10n_fr_secure_sequence_number or m.l10n_fr_hash)): + new_number = move.company_id.l10n_fr_secure_sequence_id.next_by_id() + vals_hashing = {'l10n_fr_secure_sequence_number': new_number, + 'l10n_fr_hash': move._get_new_hash(new_number)} + res |= super(AccountMove, move).write(vals_hashing) + return res + + @api.multi + def button_cancel(self): + #by-pass the normal behavior/message that tells people can cancel a posted journal entry + #if the journal allows it. + if self.company_id._is_accounting_unalterable(): + raise UserError(_('You cannot modify a posted journal entry. This ensures its inalterability.')) + super(AccountMove, self).button_cancel() + + @api.model + def _check_hash_integrity(self, company_id): + """Checks that all posted moves have still the same data as when they were posted + and raises an error with the result. + """ + def build_move_info(move): + entry_reference = _('(ref.: %s)') + move_reference_string = move.ref and entry_reference % move.ref or '' + return [move.name, move_reference_string] + + moves = self.search([('state', '=', 'posted'), + ('company_id', '=', company_id), + ('l10n_fr_secure_sequence_number', '!=', 0)], + order="l10n_fr_secure_sequence_number ASC") + + if not moves: + raise UserError(_('There isn\'t any journal entry flagged for data inalterability yet for the company %s. This mechanism only runs for journal entries generated after the installation of the module France - Certification CGI 286 I-3 bis.') % self.env.user.company_id.name) + previous_hash = '' + start_move_info = [] + for move in moves: + if move.l10n_fr_hash != move._compute_hash(previous_hash=previous_hash): + raise UserError(_('Corrupted data on journal entry with id %s.') % move.id) + if not previous_hash: + #save the date and sequence number of the first move hashed + start_move_info = build_move_info(move) + previous_hash = move.l10n_fr_hash + end_move_info = build_move_info(move) + + report_dict = {'start_move_name': start_move_info[0], + 'start_move_ref': start_move_info[1], + 'end_move_name': end_move_info[0], + 'end_move_ref': end_move_info[1]} + + # Raise on success + raise UserError(_('''Successful test ! + + The journal entries are guaranteed to be in their original and inalterable state + From: %(start_move_name)s %(start_move_ref)s + To: %(end_move_name)s %(end_move_ref)s + + For this report to be legally meaningful, please download your certification from your customer account on Odoo.com (Only for Odoo Enterprise users).''' + ) % report_dict) + + +class AccountMoveLine(models.Model): + _inherit = "account.move.line" + + @api.multi + def write(self, vals): + # restrict the operation in case we are trying to write a forbidden field + if set(vals).intersection(LINE_FIELDS): + if any(l.company_id._is_accounting_unalterable() and l.move_id.state == 'posted' for l in self): + raise UserError(_("According to the French law, you cannot modify a journal item in order for its posted data to be updated or deleted. Unauthorized field: %s.") % ', '.join(LINE_FIELDS)) + return super(AccountMoveLine, self).write(vals) + + +class AccountJournal(models.Model): + _inherit = "account.journal" + + @api.onchange('update_posted') + def _onchange_update_posted(self): + if self.update_posted and self.company_id._is_accounting_unalterable(): + field_string = self._fields['update_posted'].get_description(self.env)['string'] + raise UserError(_("According to the French law, you cannot modify a journal in order for its posted data to be updated or deleted. Unauthorized field: %s.") % field_string) + + @api.multi + def _is_journal_alterable(self): + self.ensure_one() + critical_domain = [('journal_id', '=', self.id), + '|', ('l10n_fr_hash', '!=', False), + '&', ('l10n_fr_secure_sequence_number', '!=', False), + ('l10n_fr_secure_sequence_number', '!=', 0)] + if self.env['account.move'].search(critical_domain): + raise UserError(_('It is not permitted to disable the data inalterability in this journal (%s) since journal entries have already been protected.') % (self.name, )) + return True + + @api.multi + def write(self, vals): + # restrict the operation in case we are trying to write a forbidden field + for journal in self: + if journal.company_id._is_accounting_unalterable(): + if vals.get('update_posted'): + field_string = journal._fields['update_posted'].get_description(self.env)['string'] + raise UserError(_("According to the French law, you cannot modify a journal in order for its posted data to be updated or deleted. Unauthorized field: %s.") % field_string) + return super(AccountJournal, self).write(vals) + + @api.model + def create(self, vals): + # restrict the operation in case we are trying to set a forbidden field + if self.company_id._is_accounting_unalterable(): + if vals.get('update_posted'): + field_string = self._fields['update_posted'].get_description(self.env)['string'] + raise UserError(_("According to the French law, you cannot modify a journal in order for its posted data to be updated or deleted. Unauthorized field: %s.") % field_string) + return super(AccountJournal, self).create(vals) diff --git a/addons/l10n_fr_certification/models/res_company.py b/addons/l10n_fr_certification/models/res_company.py new file mode 100644 index 00000000000..97aa993aa7e --- /dev/null +++ b/addons/l10n_fr_certification/models/res_company.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models, api + +UNALTERABLE_COUNTRIES = ['FR', 'MF', 'MQ', 'NC', 'PF', 'RE', 'GF', 'GP', 'TF'] + + +class ResCompany(models.Model): + _inherit = 'res.company' + + # To do in master : refactor to set sequences more generic + + l10n_fr_secure_sequence_id = fields.Many2one('ir.sequence', 'Sequence to use to ensure the securisation of data', readonly=True) + + @api.model + def create(self, vals): + company = super(ResCompany, self).create(vals) + #when creating a new french company, create the securisation sequence as well + if company._is_accounting_unalterable(): + sequence_fields = ['l10n_fr_secure_sequence_id'] + company._create_secure_sequence(sequence_fields) + return company + + @api.multi + def write(self, vals): + res = super(ResCompany, self).write(vals) + #if country changed to fr, create the securisation sequence + for company in self: + if company._is_accounting_unalterable(): + sequence_fields = ['l10n_fr_secure_sequence_id'] + company._create_secure_sequence(sequence_fields) + return res + + def _create_secure_sequence(self, sequence_fields): + """This function creates a no_gap sequence on each companies in self that will ensure + a unique number is given to all posted account.move in such a way that we can always + find the previous move of a journal entry. + """ + for company in self: + vals_write = {} + for seq_field in sequence_fields: + if not company[seq_field]: + vals = { + 'name': 'French Securisation of ' + seq_field + ' - ' + company.name, + 'code': 'FRSECUR', + 'implementation': 'no_gap', + 'prefix': '', + 'suffix': '', + 'padding': 0, + 'company_id': company.id} + seq = self.env['ir.sequence'].create(vals) + vals_write[seq_field] = seq.id + if vals_write: + company.write(vals_write) + + def _is_vat_french(self): + return self.vat and self.vat.startswith('FR') and len(self.vat) == 13 + + def _is_accounting_unalterable(self): + if not self.vat and not self.country_id: + return False + return self.country_id and self.country_id.code in UNALTERABLE_COUNTRIES or self._is_vat_french() diff --git a/addons/l10n_fr_certification/static/description/icon.png b/addons/l10n_fr_certification/static/description/icon.png Binary files differnew file mode 100644 index 00000000000..bbcaa4fcce7 --- /dev/null +++ b/addons/l10n_fr_certification/static/description/icon.png diff --git a/addons/l10n_fr_certification/views/res_config.xml b/addons/l10n_fr_certification/views/res_config.xml new file mode 100644 index 00000000000..7f95850cbc0 --- /dev/null +++ b/addons/l10n_fr_certification/views/res_config.xml @@ -0,0 +1,18 @@ +<odoo> + <record id="view_general_fr_inalterability_inherit_config" model="ir.ui.view"> + <field name="name">Inalterability settings</field> + <field name="model">res.config.settings</field> + <field name="inherit_id" ref="base_setup.res_config_settings_view_form"/> + <field name="arch" type="xml"> + <xpath expr="//field[@name='company_id']" position="before"> + <h2>Data Inalterability</h2> + <div> + <div> + <button name="%(l10n_fr_certification.action_check_hash_integrity)d" type="action" string="Data Inalterability Check" class="oe_link"/> + <button name="%(l10n_fr_certification.odoo_french_certification)d" type="action" string="Get the certification now!" class="oe_link"/> + </div> + </div> + </xpath> + </field> + </record> +</odoo> diff --git a/addons/l10n_fr_pos_cert/__init__.py b/addons/l10n_fr_pos_cert/__init__.py new file mode 100644 index 00000000000..eae875db876 --- /dev/null +++ b/addons/l10n_fr_pos_cert/__init__.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models +from odoo import api, SUPERUSER_ID +from ..l10n_fr_certification.models.res_company import UNALTERABLE_COUNTRIES + + +def _setup_inalterability(cr, registry): + env = api.Environment(cr, SUPERUSER_ID, {}) + # enable ping for this module + env['publisher_warranty.contract'].update_notification(cron_mode=True) + + fr_companies = env['res.company'].search([('partner_id.country_id.code', 'in', UNALTERABLE_COUNTRIES)]) + if fr_companies: + # create the securisation sequence per company + fr_companies._create_secure_sequence(['l10n_fr_pos_cert_sequence_id']) diff --git a/addons/l10n_fr_pos_cert/__manifest__.py b/addons/l10n_fr_pos_cert/__manifest__.py new file mode 100644 index 00000000000..40907b49621 --- /dev/null +++ b/addons/l10n_fr_pos_cert/__manifest__.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'France - VAT Anti-Fraud Certification for Point of Sale (CGI 286 I-3 bis)', + 'version': '1.0', + 'category': 'Localization', + 'description': """ +This add-on brings the technical requirements of the French regulation CGI art. 286, I. 3° bis that stipulates certain criteria concerning the inalterability, security, storage and archiving of data related to sales to private individuals (B2C). +----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + +Install it if you use the Point of Sale app to sell to individuals. + +The module adds following features: + + Inalterability: deactivation of all the ways to cancel or modify key data of POS orders, invoices and journal entries + + Security: chaining algorithm to verify the inalterability + + Storage: automatic sales closings with computation of both period and cumulative totals (daily, monthly, annually) + + Access to download the mandatory Certificate of Conformity delivered by Odoo SA (only for Odoo Enterprise users) +""", + 'depends': ['l10n_fr_sale_closing', 'point_of_sale'], + 'installable': True, + 'auto_install': False, + 'application': False, + 'data': [ + 'data/pos_inalterability.xml', + 'views/account_views.xml', + ], + 'post_init_hook': '_setup_inalterability', +} diff --git a/addons/l10n_fr_pos_cert/data/pos_inalterability.xml b/addons/l10n_fr_pos_cert/data/pos_inalterability.xml new file mode 100644 index 00000000000..ace4c3de24d --- /dev/null +++ b/addons/l10n_fr_pos_cert/data/pos_inalterability.xml @@ -0,0 +1,17 @@ +<odoo> + + <menuitem id="pos_fr_statements_menu" name="French Statements" parent="point_of_sale.menu_point_rep" sequence="9" /> + + <menuitem action="l10n_fr_sale_closing.action_list_view_account_sale_closing" id="menu_account_closing" parent="pos_fr_statements_menu" sequence="90"/> + + <record model="ir.actions.server" id="action_check_hash_integrity_pos_orders"> + <field name="name">Pos Orders Inalterability Check</field> + <field name="model_id" ref="point_of_sale.model_pos_order"/> + <field name="type">ir.actions.server</field> + <field name="code"> +action = env['pos.order']._check_hash_integrity(env.user.company_id.id) + </field> + </record> + + <menuitem action="action_check_hash_integrity_pos_orders" id="menu_check_pos_order_integrity" parent="pos_fr_statements_menu" sequence="90"/> +</odoo>
\ No newline at end of file diff --git a/addons/l10n_fr_pos_cert/models/__init__.py b/addons/l10n_fr_pos_cert/models/__init__.py new file mode 100644 index 00000000000..76de40a9e62 --- /dev/null +++ b/addons/l10n_fr_pos_cert/models/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import account_bank_statement +from . import res_company +from . import pos diff --git a/addons/l10n_fr_pos_cert/models/account_bank_statement.py b/addons/l10n_fr_pos_cert/models/account_bank_statement.py new file mode 100644 index 00000000000..354de99ddd6 --- /dev/null +++ b/addons/l10n_fr_pos_cert/models/account_bank_statement.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import models +from odoo.tools.translate import _ +from odoo.exceptions import UserError + + +class AccountBankStatement(models.Model): + _inherit = 'account.bank.statement' + + 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,)) + return super(AccountBankStatement, self).unlink() + + +class AccountBankStatementLine(models.Model): + _inherit = 'account.bank.statement.line' + + 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,)) + return super(AccountBankStatementLine, self).unlink() diff --git a/addons/l10n_fr_pos_cert/models/pos.py b/addons/l10n_fr_pos_cert/models/pos.py new file mode 100644 index 00000000000..f3a35a5a878 --- /dev/null +++ b/addons/l10n_fr_pos_cert/models/pos.py @@ -0,0 +1,196 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from datetime import datetime, timedelta +from hashlib import sha256 +from json import dumps +import pytz + +from odoo import models, api, fields +from odoo.fields import Datetime +from odoo.tools.translate import _ +from odoo.exceptions import UserError + + +def ctx_tz(record, field): + res_lang = None + ctx = record._context + tz_name = pytz.timezone(ctx.get('tz') or record.env.user.tz) + timestamp = Datetime.from_string(record[field]) + if ctx.get('lang'): + res_lang = record.env['res.lang'].search([('code', '=', ctx['lang'])], limit=1) + if res_lang: + timestamp = pytz.utc.localize(timestamp, is_dst=False) + return datetime.strftime(timestamp.astimezone(tz_name), res_lang.date_format + ' ' + res_lang.time_format) + return Datetime.context_timestamp(record, timestamp) + + +class pos_config(models.Model): + _inherit = 'pos.config' + + @api.multi + def open_ui(self): + for config in self.filtered(lambda c: c.company_id._is_accounting_unalterable()): + if config.current_session_id: + config.current_session_id._check_session_timing() + return super(pos_config, self).open_ui() + + +class pos_session(models.Model): + _inherit = 'pos.session' + + @api.multi + def _check_session_timing(self): + self.ensure_one() + date_today = datetime.utcnow() + session_start = Datetime.from_string(self.start_at) + if not date_today - timedelta(hours=24) <= session_start: + raise UserError(_("This session has been opened another day. To comply with the French law, you should close sessions on a daily basis. Please close session %s and open a new one.") % self.name) + return True + + @api.multi + def open_frontend_cb(self): + for session in self.filtered(lambda s: s.config_id.company_id._is_accounting_unalterable()): + session._check_session_timing() + return super(pos_session, self).open_frontend_cb() + + +ORDER_FIELDS = ['date_order', 'user_id', 'lines', 'statement_ids', 'pricelist_id', 'partner_id', 'session_id', 'pos_reference', 'sale_journal', 'fiscal_position_id'] +LINE_FIELDS = ['notice', 'product_id', 'qty', 'price_unit', 'discount', 'tax_ids', 'tax_ids_after_fiscal_position'] +ERR_MSG = _('According to the French law, you cannot modify a %s. Forbidden fields: %s.') + + +class pos_order(models.Model): + _inherit = 'pos.order' + + l10n_fr_hash = fields.Char(string="Inalteralbility Hash", readonly=True, copy=False) + l10n_fr_secure_sequence_number = fields.Integer(string="Inalteralbility No Gap Sequence #", readonly=True, copy=False) + l10n_fr_string_to_hash = fields.Char(compute='_compute_string_to_hash', readonly=True, store=False) + + def _get_new_hash(self, secure_seq_number): + """ Returns the hash to write on pos orders when they get posted""" + self.ensure_one() + #get the only one exact previous order in the securisation sequence + prev_order = self.search([('state', 'in', ['paid', 'done', 'invoiced']), + ('company_id', '=', self.company_id.id), + ('l10n_fr_secure_sequence_number', '!=', 0), + ('l10n_fr_secure_sequence_number', '=', int(secure_seq_number) - 1)]) + if prev_order and len(prev_order) != 1: + raise UserError( + _('An error occured when computing the inalterability. Impossible to get the unique previous posted point of sale order.')) + + #build and return the hash + return self._compute_hash(prev_order.l10n_fr_hash if prev_order else '') + + def _compute_hash(self, previous_hash): + """ Computes the hash of the browse_record given as self, based on the hash + of the previous record in the company's securisation sequence given as parameter""" + self.ensure_one() + hash_string = sha256(previous_hash + self.l10n_fr_string_to_hash) + return hash_string.hexdigest() + + def _compute_string_to_hash(self): + def _getattrstring(obj, field_str): + field_value = obj[field_str] + if obj._fields[field_str].type == 'many2one': + field_value = field_value.id + if obj._fields[field_str].type in ['many2many', 'one2many']: + field_value = field_value.ids + return str(field_value) + + for order in self: + values = {} + for field in ORDER_FIELDS: + values[field] = _getattrstring(order, field) + + for line in order.lines: + for field in LINE_FIELDS: + k = 'line_%d_%s' % (line.id, field) + values[k] = _getattrstring(line, field) + #make the json serialization canonical + # (https://tools.ietf.org/html/draft-staykov-hu-json-canonical-form-00) + order.l10n_fr_string_to_hash = dumps(values, sort_keys=True, encoding="utf-8", + ensure_ascii=True, indent=None, + separators=(',',':')) + + @api.multi + def write(self, vals): + has_been_posted = False + for order in self: + if order.company_id._is_accounting_unalterable(): + # write the hash and the secure_sequence_number when posting or invoicing an pos.order + if vals.get('state') in ['paid', 'done', 'invoiced']: + has_been_posted = True + + # restrict the operation in case we are trying to write a forbidden field + if (order.state in ['paid', 'done', 'invoiced'] and set(vals).intersection(ORDER_FIELDS)): + raise UserError(_('According to the French law, you cannot modify a point of sale order. Forbidden fields: %s.') % ', '.join(ORDER_FIELDS)) + # restrict the operation in case we are trying to overwrite existing hash + if (order.l10n_fr_hash and 'l10n_fr_hash' in vals) or (order.l10n_fr_secure_sequence_number and 'l10n_fr_secure_sequence_number' in vals): + raise UserError(_('You cannot overwrite the values ensuring the inalterability of the point of sale.')) + res = super(pos_order, self).write(vals) + # write the hash and the secure_sequence_number when posting or invoicing a pos order + if has_been_posted: + for order in self.filtered(lambda o: o.company_id._is_accounting_unalterable() and + not (o.l10n_fr_secure_sequence_number or o.l10n_fr_hash)): + new_number = order.company_id.l10n_fr_pos_cert_sequence_id.next_by_id() + vals_hashing = {'l10n_fr_secure_sequence_number': new_number, + 'l10n_fr_hash': order._get_new_hash(new_number)} + res |= super(pos_order, order).write(vals_hashing) + return res + + @api.model + def _check_hash_integrity(self, company_id): + """Checks that all posted or invoiced pos orders have still the same data as when they were posted + and raises an error with the result. + """ + def build_order_info(order): + entry_reference = _('(Receipt ref.: %s)') + order_reference_string = order.pos_reference and entry_reference % order.pos_reference or '' + return [ctx_tz(order, 'date_order'), order.l10n_fr_secure_sequence_number, order.name, order_reference_string, ctx_tz(order, 'write_date')] + + orders = self.search([('state', 'in', ['paid', 'done', 'invoiced']), + ('company_id', '=', company_id), + ('l10n_fr_secure_sequence_number', '!=', 0)], + order="l10n_fr_secure_sequence_number ASC") + + if not orders: + raise UserError(_('There isn\'t any order flagged for data inalterability yet for the company %s. This mechanism only runs for point of sale orders generated after the installation of the module France - Certification CGI 286 I-3 bis. - POS') % self.env.user.company_id.name) + previous_hash = '' + start_order_info = [] + for order in orders: + if order.l10n_fr_hash != order._compute_hash(previous_hash=previous_hash): + raise UserError(_('Corrupted data on point of sale order with id %s.') % order.id) + previous_hash = order.l10n_fr_hash + + orders_sorted_date = orders.sorted(lambda o: o.date_order) + start_order_info = build_order_info(orders_sorted_date[0]) + end_order_info = build_order_info(orders_sorted_date[-1]) + + report_dict = {'start_order_name': start_order_info[2], + 'start_order_ref': start_order_info[3], + 'start_order_date': start_order_info[0], + 'end_order_name': end_order_info[2], + 'end_order_ref': end_order_info[3], + 'end_order_date': end_order_info[0]} + + # Raise on success + raise UserError(_('''Successful test ! + + The point of sale orders are guaranteed to be in their original and inalterable state + From: %(start_order_name)s %(start_order_ref)s recorded on %(start_order_date)s + To: %(end_order_name)s %(end_order_ref)s recorded on %(end_order_date)s + + For this report to be legally meaningful, please download your certification from your customer account on Odoo.com (Only for Odoo Enterprise users).''' + ) % report_dict) + + +class PosOrderLine(models.Model): + _inherit = "pos.order.line" + + @api.multi + def write(self, vals): + # restrict the operation in case we are trying to write a forbidden field + if set(vals).intersection(LINE_FIELDS): + if any(l.company_id._is_accounting_unalterable() and l.order_id.state in ['done', 'invoiced'] for l in self): + raise UserError(_('According to the French law, you cannot modify a point of sale order line. Forbidden fields: %s.') % ', '.join(LINE_FIELDS)) + return super(PosOrderLine, self).write(vals) diff --git a/addons/l10n_fr_pos_cert/models/res_company.py b/addons/l10n_fr_pos_cert/models/res_company.py new file mode 100644 index 00000000000..0b11d2bec8d --- /dev/null +++ b/addons/l10n_fr_pos_cert/models/res_company.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import models, api, fields + + +class ResCompany(models.Model): + _inherit = 'res.company' + + l10n_fr_pos_cert_sequence_id = fields.Many2one('ir.sequence') + + @api.model + def create(self, vals): + company = super(ResCompany, self).create(vals) + #when creating a new french company, create the securisation sequence as well + if company._is_accounting_unalterable(): + sequence_fields = ['l10n_fr_pos_cert_sequence_id'] + company._create_secure_sequence(sequence_fields) + return company + + @api.multi + def write(self, vals): + res = super(ResCompany, self).write(vals) + #if country changed to fr, create the securisation sequence + for company in self: + if company._is_accounting_unalterable(): + sequence_fields = ['l10n_fr_pos_cert_sequence_id'] + company._create_secure_sequence(sequence_fields) + return res diff --git a/addons/l10n_fr_pos_cert/static/description/icon.png b/addons/l10n_fr_pos_cert/static/description/icon.png Binary files differnew file mode 100644 index 00000000000..bbcaa4fcce7 --- /dev/null +++ b/addons/l10n_fr_pos_cert/static/description/icon.png diff --git a/addons/l10n_fr_pos_cert/views/account_views.xml b/addons/l10n_fr_pos_cert/views/account_views.xml new file mode 100644 index 00000000000..bfe6100061f --- /dev/null +++ b/addons/l10n_fr_pos_cert/views/account_views.xml @@ -0,0 +1,26 @@ +<odoo> + <record id="view_bank_statement_form_readonly" model="ir.ui.view"> + <field name="name">account.bank.statement.form</field> + <field name="model">account.bank.statement</field> + <field name="inherit_id" ref="account.view_bank_statement_form"/> + <field name="mode">extension</field> + <field name="arch" type="xml"> + <xpath expr="//form" position="inside"> + <field name="pos_session_id" invisible="1"/> + </xpath> + <xpath expr="//field[@name='name']" position="attributes"> + <attribute name="attrs">{'readonly': [('pos_session_id', '!=', False)]}</attribute> + </xpath> + <xpath expr="//field[@name='journal_id']" position="attributes"> + <attribute name="attrs">{'readonly': [('pos_session_id', '!=', False)]}</attribute> + </xpath> + <xpath expr="//field[@name='date']" position="attributes"> + <attribute name="attrs">{'readonly': [('pos_session_id', '!=', False)]}</attribute> + </xpath> + <xpath expr="//field[@name='line_ids']" position="attributes"> + <attribute name="attrs">{'readonly': [('pos_session_id', '!=', False)]}</attribute> + </xpath> + + </field> + </record> +</odoo>
\ No newline at end of file diff --git a/addons/l10n_fr_sale_closing/__init__.py b/addons/l10n_fr_sale_closing/__init__.py new file mode 100644 index 00000000000..d8e1ab76276 --- /dev/null +++ b/addons/l10n_fr_sale_closing/__init__.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import models +from odoo import api, SUPERUSER_ID +from ..l10n_fr_certification.models.res_company import UNALTERABLE_COUNTRIES + + +def _setup_inalterability(cr, registry): + env = api.Environment(cr, SUPERUSER_ID, {}) + # enable ping for this module + env['publisher_warranty.contract'].update_notification(cron_mode=True) + + fr_companies = env['res.company'].search([('partner_id.country_id.code', 'in', UNALTERABLE_COUNTRIES)]) + if fr_companies: + # create the securisation sequence per company + fr_companies._create_secure_sequence(['l10n_fr_closing_sequence_id']) diff --git a/addons/l10n_fr_sale_closing/__manifest__.py b/addons/l10n_fr_sale_closing/__manifest__.py new file mode 100644 index 00000000000..83fcad8f626 --- /dev/null +++ b/addons/l10n_fr_sale_closing/__manifest__.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'France - VAT Anti-Fraud Certification (CGI 286 I-3 bis) - Sale Closings', + 'version': '1.0', + 'category': 'Localization', + 'description': """ +This module adds support for automatic sales closings with computation of both period and cumulative totals (daily, monthly, annually) +----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + +The module adds following features: + + Storage: automatic sales closings with computation of both period and cumulative totals (daily, monthly, annually) + +""", + 'depends': ['l10n_fr_certification'], + 'installable': True, + 'auto_install': True, + 'application': False, + 'data': [ + 'views/account_sale_closure.xml', + 'data/account_sale_closure_cron.xml', + 'security/ir.model.access.csv', + 'security/account_closing_intercompany.xml', + ], + 'post_init_hook': '_setup_inalterability', +} diff --git a/addons/l10n_fr_sale_closing/data/account_sale_closure_cron.xml b/addons/l10n_fr_sale_closing/data/account_sale_closure_cron.xml new file mode 100644 index 00000000000..ba2bd2f3a4b --- /dev/null +++ b/addons/l10n_fr_sale_closing/data/account_sale_closure_cron.xml @@ -0,0 +1,34 @@ +<odoo> + <record model="ir.cron" id="account_sale_closing_daily"> + <field name="name">Generate Daily Sales Closing</field> + <field name="interval_number">1</field> + <field name="interval_type">days</field> + <field name="numbercall">-1</field> + <field name="doall" eval="False"/> + <field name="model_id" ref="model_account_sale_closing"/> + <field name="state">code</field> + <field name="code">model._automated_closing('daily')</field> + </record> + + <record model="ir.cron" id="account_sale_closing_monthly"> + <field name="name">Generate Monthly Sales Closing</field> + <field name="interval_number">1</field> + <field name="interval_type">months</field> + <field name="numbercall">-1</field> + <field name="doall" eval="False"/> + <field name="model_id" ref="model_account_sale_closing"/> + <field name="state">code</field> + <field name="code">model._automated_closing('monthly')</field> + </record> + + <record model="ir.cron" id="account_sale_closing_annually"> + <field name="name">Generate Annual Sales Closing</field> + <field name="interval_number">12</field> + <field name="interval_type">months</field> + <field name="numbercall">-1</field> + <field name="doall" eval="False"/> + <field name="model_id" ref="model_account_sale_closing"/> + <field name="state">code</field> + <field name="code">model._automated_closing('annually')</field> + </record> +</odoo> diff --git a/addons/l10n_fr_sale_closing/models/__init__.py b/addons/l10n_fr_sale_closing/models/__init__.py new file mode 100644 index 00000000000..01a60f4b64d --- /dev/null +++ b/addons/l10n_fr_sale_closing/models/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import res_company +from . import account_closing diff --git a/addons/l10n_fr_sale_closing/models/account_closing.py b/addons/l10n_fr_sale_closing/models/account_closing.py new file mode 100644 index 00000000000..20b4b62b0ef --- /dev/null +++ b/addons/l10n_fr_sale_closing/models/account_closing.py @@ -0,0 +1,160 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from datetime import datetime, timedelta + +from odoo import models, api, fields +from odoo.fields import Datetime as FieldDateTime +from odoo.tools.translate import _ +from odoo.exceptions import UserError + + +class AccountClosing(models.Model): + """ + This object holds an interval total and a grand total of the accounts of type receivable for a company, + as well as the last account_move that has been counted in a previous object + It takes its earliest brother to infer from when the computation needs to be done + in order to compute its own data. + """ + _name = 'account.sale.closing' + _order = 'date_closing_stop desc, sequence_number desc' + + name = fields.Char(help="Frequency and unique sequence number", required=True) + company_id = fields.Many2one('res.company', string='Company', readonly=True, required=True) + date_closing_stop = fields.Datetime(string="Closing Date", help='Date to which the values are computed', readonly=True, required=True) + date_closing_start = fields.Datetime(string="Starting Date", help='Date from which the total interval is computed', readonly=True, required=True) + frequency = fields.Selection(string='Closing Type', selection=[('daily', 'Daily'), ('monthly', 'Monthly'), ('annually', 'Annual')], readonly=True, required=True) + total_interval = fields.Monetary(string="Period Total", help='Total in receivable accounts during the interval, excluding overlapping periods', readonly=True, required=True) + cumulative_total = fields.Monetary(string="Cumulative Grand Total", help='Total in receivable accounts since the beginnig of times', readonly=True, required=True) + sequence_number = fields.Integer('Sequence #', readonly=True, required=True) + last_move_id = fields.Many2one('account.move', string='Last journal entry', help='Last Journal entry included in the grand total', readonly=True) + last_move_hash = fields.Char(string='Last journal entry\'s inalteralbility hash', readonly=True) + currency_id = fields.Many2one('res.currency', string='Currency', help="The company's currency", readonly=True, related='company_id.currency_id', store=True) + + def _query_for_aml(self, company, first_move_sequence_number, date_start): + params = {'company_id': company.id} + query = '''WITH aggregate AS (SELECT m.id AS move_id, + aml.balance AS balance, + aml.id as line_id + FROM account_move_line aml + JOIN account_journal j ON aml.journal_id = j.id + JOIN account_account acc ON acc.id = aml.account_id + JOIN account_account_type t ON (t.id = acc.user_type_id AND t.type = 'receivable') + JOIN account_move m ON m.id = aml.move_id + WHERE j.type = 'sale' + AND aml.company_id = %(company_id)s + AND m.state = 'posted' ''' + + if first_move_sequence_number is not False and first_move_sequence_number is not None: + params['first_move_sequence_number'] = first_move_sequence_number + query += '''AND m.l10n_fr_secure_sequence_number > %(first_move_sequence_number)s''' + elif date_start: + #the first time we compute the closing, we consider only from the installation of the module + params['date_start'] = date_start + query += '''AND m.date >= %(date_start)s''' + + query += " ORDER BY m.l10n_fr_secure_sequence_number DESC) " + query += '''SELECT array_agg(move_id) AS move_ids, + array_agg(line_id) AS line_ids, + sum(balance) AS balance + FROM aggregate''' + + self.env.cr.execute(query, params) + return self.env.cr.dictfetchall()[0] + + def _compute_amounts(self, frequency, company): + """ + Method used to compute all the business data of the new object. + It will search for previous closings of the same frequency to infer the move from which + account move lines should be fetched. + @param {string} frequency: a valid value of the selection field on the object (daily, monthly, annually) + frequencies are literal (daily means 24 hours and so on) + @param {recordset} company: the company for which the closing is done + @return {dict} containing {field: value} for each business field of the object + """ + interval_dates = self._interval_dates(frequency, company) + previous_closing = self.search([ + ('frequency', '=', frequency), + ('company_id', '=', company.id)], limit=1, order='sequence_number desc') + + first_move = self.env['account.move'] + date_start = interval_dates['interval_from'] + cumulative_total = 0 + if previous_closing: + first_move = previous_closing.last_move_id + date_start = previous_closing.create_date + cumulative_total += previous_closing.cumulative_total + + aml_aggregate = self._query_for_aml(company, first_move.l10n_fr_secure_sequence_number, date_start) + + total_interval = aml_aggregate['balance'] or 0 + cumulative_total += total_interval + + # We keep the reference to avoid gaps (like daily object during the weekend) + last_move = first_move + if aml_aggregate['move_ids']: + last_move = last_move.browse(aml_aggregate['move_ids'][0]) + + return {'total_interval': total_interval, + 'cumulative_total': cumulative_total, + 'last_move_id': last_move.id, + 'last_move_hash': last_move.l10n_fr_hash, + 'date_closing_stop': interval_dates['date_stop'], + 'date_closing_start': date_start, + 'name': interval_dates['name_interval'] + ' - ' + interval_dates['date_stop'][:10]} + + def _interval_dates(self, frequency, company): + """ + Method used to compute the theoretical date from which account move lines should be fetched + @param {string} frequency: a valid value of the selection field on the object (daily, monthly, annually) + frequencies are literal (daily means 24 hours and so on) + @param {recordset} company: the company for which the closing is done + @return {dict} the theoretical date from which account move lines are fetched. + date_stop date to which the move lines are fetched, always now() + the dates are in their Odoo Database string representation + """ + date_stop = datetime.utcnow() + interval_from = None + name_interval = '' + if frequency == 'daily': + interval_from = date_stop - timedelta(days=1) + name_interval = _('Daily Closing') + elif frequency == 'monthly': + month_target = date_stop.month > 1 and date_stop.month - 1 or 12 + year_target = month_target < 12 and date_stop.year or date_stop.year - 1 + interval_from = date_stop.replace(year=year_target, month=month_target) + name_interval = _('Monthly Closing') + elif frequency == 'annually': + year_target = date_stop.year - 1 + interval_from = date_stop.replace(year=year_target) + name_interval = _('Annual Closing') + + return {'interval_from': FieldDateTime.to_string(interval_from), + 'date_stop': FieldDateTime.to_string(date_stop), + 'name_interval': name_interval} + + @api.multi + def write(self, vals): + raise UserError(_('Sale Closings are not meant to be written or deleted under any circumstances.')) + + @api.multi + def unlink(self): + raise UserError(_('Sale Closings are not meant to be written or deleted under any circumstances.')) + + @api.model + def _automated_closing(self, frequency='daily'): + """To be executed by the CRON to create an object of the given frequency for each company that needs it + @param {string} frequency: a valid value of the selection field on the object (daily, monthly, annually) + frequencies are literal (daily means 24 hours and so on) + @return {recordset} all the objects created for the given frequency + """ + res_company = self.env['res.company'].search([]) + account_closings = self.env['account.sale.closing'] + for company in res_company.filtered(lambda c: c._is_accounting_unalterable()): + new_sequence_number = company.l10n_fr_closing_sequence_id.next_by_id() + values = self._compute_amounts(frequency, company) + values['frequency'] = frequency + values['company_id'] = company.id + values['sequence_number'] = new_sequence_number + account_closings |= account_closings.create(values) + + return account_closings diff --git a/addons/l10n_fr_sale_closing/models/res_company.py b/addons/l10n_fr_sale_closing/models/res_company.py new file mode 100644 index 00000000000..7e623ff5d3e --- /dev/null +++ b/addons/l10n_fr_sale_closing/models/res_company.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models, api + + +class ResCompany(models.Model): + _inherit = 'res.company' + + l10n_fr_closing_sequence_id = fields.Many2one('ir.sequence', 'Sequence to use to build sale closings', readonly=True) + + @api.model + def create(self, vals): + company = super(ResCompany, self).create(vals) + #when creating a new french company, create the securisation sequence as well + if company._is_accounting_unalterable(): + sequence_fields = ['l10n_fr_closing_sequence_id'] + company._create_secure_sequence(sequence_fields) + return company + + @api.multi + def write(self, vals): + res = super(ResCompany, self).write(vals) + #if country changed to fr, create the securisation sequence + for company in self: + if company._is_accounting_unalterable(): + sequence_fields = ['l10n_fr_closing_sequence_id'] + company._create_secure_sequence(sequence_fields) + return res diff --git a/addons/l10n_fr_sale_closing/security/account_closing_intercompany.xml b/addons/l10n_fr_sale_closing/security/account_closing_intercompany.xml new file mode 100644 index 00000000000..6a8215852aa --- /dev/null +++ b/addons/l10n_fr_sale_closing/security/account_closing_intercompany.xml @@ -0,0 +1,8 @@ +<odoo> + <record model="ir.rule" id="account_sale_closing_multi_company"> + <field name="name">Sale Closing multi-company</field> + <field name="model_id" ref="model_account_sale_closing"/> + <field name="domain_force">[('company_id','child_of',[user.company_id.id])]</field> + </record> + +</odoo> diff --git a/addons/l10n_fr_sale_closing/security/ir.model.access.csv b/addons/l10n_fr_sale_closing/security/ir.model.access.csv new file mode 100644 index 00000000000..32a4e37f3d0 --- /dev/null +++ b/addons/l10n_fr_sale_closing/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_l10n_fr_sale_closing_account_sale_closing_user,l10n_fr_sale_closing.account.sale.closing.user,l10n_fr_sale_closing.model_account_sale_closing,base.group_user,1,0,0,0
\ No newline at end of file diff --git a/addons/l10n_fr_sale_closing/static/description/icon.png b/addons/l10n_fr_sale_closing/static/description/icon.png Binary files differnew file mode 100644 index 00000000000..bbcaa4fcce7 --- /dev/null +++ b/addons/l10n_fr_sale_closing/static/description/icon.png diff --git a/addons/l10n_fr_sale_closing/views/account_sale_closure.xml b/addons/l10n_fr_sale_closing/views/account_sale_closure.xml new file mode 100644 index 00000000000..97ddeb05056 --- /dev/null +++ b/addons/l10n_fr_sale_closing/views/account_sale_closure.xml @@ -0,0 +1,68 @@ +<odoo> + <record id="list_view_account_sale_closing" model="ir.ui.view"> + <field name="name">Sales Closings</field> + <field name="model">account.sale.closing</field> + <field name="arch" type="xml"> + <tree create="false" import="false"> + <field name="date_closing_start"/> + <field name="date_closing_stop"/> + <field name="company_id" groups="base.group_multi_company"/> + <field name="currency_id" invisible="1"/> + <field name="frequency"/> + <field name="sequence_number" groups="base.group_no_one"/> + <field name="total_interval"/> + <field name="cumulative_total"/> + </tree> + </field> + </record> + + <record id="form_view_account_sale_closing" model="ir.ui.view"> + <field name="name">Sales Closings</field> + <field name="model">account.sale.closing</field> + <field name="arch" type="xml"> + <form create="false" edit="false" string="Account Closing"> + <sheet> + <div class="oe_title"> + <h1> + <field name="name"/> + </h1> + </div> + <group> + <group> + <field name="date_closing_start"/> + <field name="date_closing_stop"/> + <field name="frequency"/> + <field name="sequence_number" groups="base.group_no_one"/> + </group> + <group> + <field name="total_interval"/> + <field name="cumulative_total"/> + <field name="last_move_id" groups="account.group_account_user"/> + <field name="last_move_hash" groups="account.group_account_user"/> + </group> + <group> + <field name="company_id" groups="base.group_multi_company"/> + <field name="currency_id" invisible="1"/> + </group> + </group> + </sheet> + </form> + </field> + </record> + + <record id="action_list_view_account_sale_closing" model="ir.actions.act_window"> + <field name="name">Sales Closings</field> + <field name="res_model">account.sale.closing</field> + <field name="view_type">tree</field> + <field name="view_mode">list,form</field> + <field name="help" type="html"> + <p class="oe_view_nocontent_nocreate"> + The closings are created by Odoo + </p><p> + Sales closings run automatically on a daily, monthly and annual basis. It computes both period and cumulative totals from all the sales entries posted in the system after the previous closing. + </p> + </field> + </record> + + <menuitem action="action_list_view_account_sale_closing" id="menu_account_closing_reporting" parent="l10n_fr.account_reports_fr_statements_menu" sequence="90"/> +</odoo> diff --git a/addons/l10n_in_hr_payroll/data/l10n_in_hr_payroll_data.xml b/addons/l10n_in_hr_payroll/data/l10n_in_hr_payroll_data.xml index 17ec10ad01d..948039f1545 100644 --- a/addons/l10n_in_hr_payroll/data/l10n_in_hr_payroll_data.xml +++ b/addons/l10n_in_hr_payroll/data/l10n_in_hr_payroll_data.xml @@ -160,7 +160,7 @@ or (contract.wage * 0.10))</field> <field name="name">Arrears</field> <field name="category_id" ref="hr_payroll.ALW"/> <field name="amount_select">code</field> - <field name="amount_python_compute">result = inputs.ARS.amount</field> + <field name="amount_python_compute">result = inputs.ARS and inputs.ARS.amount</field> <field eval="0.0" name="amount_fix"/> <field name="sequence" eval="28"/> <field name="note">Generally arrears are fully taxable, but employee may claim exemption u/s 89(1). @@ -185,7 +185,7 @@ Now difference of income tax between payment year and actual year would be allow <field name="category_id" ref="hr_payroll.ALW"/> <field name="condition_select">none</field> <field name="amount_select">code</field> - <field name="amount_python_compute">result = inputs.LE.amount</field> + <field name="amount_python_compute">result = inputs.LE and inputs.LE.amount</field> <field name="sequence" eval="30"/> <field name="note">Payment by way of leave encashment received by Central and State Govt.employees at the time of retirement in respect of the period of earned leave at credit is fully exempt. In case of other employees, the exemption is to be limited to minimum of all below: 1.The actual amount received @@ -199,7 +199,7 @@ Now difference of income tax between payment year and actual year would be allow <field name="name">Performance Incentive</field> <field name="category_id" ref="hr_payroll.ALW"/> <field name="amount_select">code</field> - <field name="amount_python_compute">result = inputs.PERF.amount</field> + <field name="amount_python_compute">result = inputs.PERF and inputs.PERF.amount</field> <field name="sequence" eval="31"/> <field name="note">This would be fully taxable based on incentive.</field> </record> @@ -209,7 +209,7 @@ Now difference of income tax between payment year and actual year would be allow <field name="name">Bonus</field> <field name="category_id" ref="hr_payroll.ALW"/> <field name="amount_select">code</field> - <field name="amount_python_compute">result = inputs.BNS.amount</field> + <field name="amount_python_compute">result = inputs.BNS and inputs.BNS.amount</field> <field name="sequence" eval="41"/> <field name="note">This would be fully taxable based on Bonus.</field> </record> @@ -229,7 +229,7 @@ Now difference of income tax between payment year and actual year would be allow <field name="name">Medical Reimbursement</field> <field name="category_id" ref="hr_payroll.ALW"/> <field name="amount_select">code</field> - <field name="amount_python_compute">result = inputs.MR.amount</field> + <field name="amount_python_compute">result = inputs.MR and inputs.MR.amount</field> <field name="sequence" eval="32"/> <field name="note">This component is on-taxable up to 15000 per year (or Rs 1250 per month) on producing medical bills.</field> </record> @@ -250,7 +250,7 @@ Now difference of income tax between payment year and actual year would be allow <field name="name">Periodical Journals</field> <field name="category_id" ref="hr_payroll.ALW"/> <field name="amount_select">code</field> - <field name="amount_python_compute">result = inputs.PJ.amount</field> + <field name="amount_python_compute">result = inputs.PJ and inputs.PJ.amount</field> <field name="sequence" eval="34"/> <field name="note">Some employers may provide component for buying magazines, journals and books as a part of knowledge enhancement for business growth.This part would become non taxable on providing original bills.</field> </record> @@ -280,7 +280,7 @@ Now difference of income tax between payment year and actual year would be allow <field name="name">Telephone Reimbursement</field> <field name="category_id" ref="hr_payroll.ALW"/> <field name="amount_select">code</field> - <field name="amount_python_compute">result = inputs.TR.amount</field> + <field name="amount_python_compute">result = inputs.TR and inputs.TR.amount</field> <field name="sequence" eval="36"/> <field name="note">In some of the cases, companies may provide a component for telephone bills.Employees may provide actual phone usage bills to reimburse this component and make it non-taxable. </field> @@ -300,7 +300,7 @@ Now difference of income tax between payment year and actual year would be allow <field name="name">Car Expenses Reimbursement</field> <field name="category_id" ref="hr_payroll.ALW"/> <field name="amount_select">code</field> - <field name="amount_python_compute">result = inputs.CEI.amount</field> + <field name="amount_python_compute">result = inputs.CEI and inputs.CEI.amount</field> <field name="sequence" eval="38"/> <field name="note">In case company provides component for this and employee use self owned car for official and personal purposes, Rs 1800 per month would be non-taxable on showing bills for fuel or can maintenance. This amount would be Rs 2400 in case car is more capacity than 1600cc.</field> </record> @@ -310,7 +310,7 @@ Now difference of income tax between payment year and actual year would be allow <field name="name">Internet Expense</field> <field name="category_id" ref="hr_payroll.ALW"/> <field name="amount_select">code</field> - <field name="amount_python_compute">result = inputs.IE.amount</field> + <field name="amount_python_compute">result = inputs.IE and inputs.IE.amount</field> <field name="sequence" eval="39"/> <field name="note">Employer may also provide reimbursement of internet expenses and thus this would become non taxable.</field> </record> @@ -463,7 +463,7 @@ or - (contract.wage * contract.voluntary_provident_fund / 100))</field> <field name="category_id" ref="hr_payroll.DED"/> <field name="condition_select">none</field> <field name="amount_select">code</field> - <field name="amount_python_compute">result = - (inputs.LAI.amount)</field> + <field name="amount_python_compute">result = inputs.LAI and - (inputs.LAI.amount)</field> <field name="register_id" ref="hr_leave_availed_register"/> <field name="sequence" eval="180"/> </record> diff --git a/addons/l10n_lu/data/account.account.tag.csv b/addons/l10n_lu/data/account.account.tag.csv index 93c9c280973..f25178a068e 100644 --- a/addons/l10n_lu/data/account.account.tag.csv +++ b/addons/l10n_lu/data/account.account.tag.csv @@ -179,20 +179,17 @@ tag_lu_177,I.B.5. Tabacs fabriqués,taxes tag_lu_178,Base - Vente Biens Tabacs - Extracommunautaire,taxes tag_lu_179,Base - Vente Biens Tabacs - Intracommunautaire,taxes tag_lu_180,Base - Vente Biens Tabacs - Pays,taxes -tag_lu_181,I.B.6.a) Livraisons subséquentes à des acquisitions intracommunautaires dans -le cadre d'opérations triangulaires, lorsque le destinataire identifié à la TVA dans l'Etat membre de destination des biens y est le redevable de la taxe (4),taxes +tag_lu_181,"I.B.6.a) Livraisons subséquentes à des acquisitions intracommunautaires dans le cadre d'opérations triangulaires, lorsque le destinataire identifié à la TVA dans l'Etat membre de destination des biens y est le redevable de la taxe (4)",taxes tag_lu_182,Base - Vente Biens – Triangulaire Intracommunautaire,taxes tag_lu_183,I.B.6.d) Autres opérations réalisées (imposables) à l'étranger,taxes tag_lu_184,Base - Vente Prestations - Extracommunautaire,taxes tag_lu_185,I.B.6.c) Opérations réalisées dans le cadre du régime particulier de l'article 56sexies,taxes tag_lu_186,I.B.7. Opérations à l'intérieur du pays pour lesquelles le preneur est le redevable,taxes -tag_lu_187,I.B.6.b)1) Prestations de services à des identifiés à la TVA dans un autre -Etat membre non exonérées dans l'Etat membre du preneur redevable (art.17/1/b) (5),taxes +tag_lu_187,I.B.6.b)1) Prestations de services à des identifiés à la TVA dans un autre Etat membre non exonérées dans l'Etat membre du preneur redevable (art.17/1/b) (5),taxes tag_lu_188,Base - Vente Prestations - Intracommunautaire,taxes -tag_lu_189,I.B.6.b)2) Prestations de services à des identifiés à la TVA dans un autre -Etat membre exonérées dans l'Etat membre du preneur (art.17/1/b),taxes +tag_lu_189,I.B.6.b)2) Prestations de services à des identifiés à la TVA dans un autre Etat membre exonérées dans l'Etat membre du preneur (art.17/1/b),taxes tag_lu_190,Base - Vente Prestations - Intracommunautaire exonérées dans l'état membre,taxes -tag_lu_191,I.B.1. Livraisons intracommunautaires de biens à des personnes identifiées à la TVA dans unautre Etat membre (art.43/1/d e et f) (3),taxes +tag_lu_191,I.B.1. Livraisons intracommunautaires de biens à des personnes identifiées à la TVA dans un autre Etat membre (art.43/1/d e et f) (3),taxes tag_lu_192,Base - Vente Biens - Intracommunautaire,taxes tag_lu_193,I.C. Chiffre d'affaires imposable,taxes tag_lu_194,II.A. Ventilation du chiffre d'affaires imposable – base,taxes @@ -598,4 +595,4 @@ tag_lu_594,Taxe - Investissement Prestations 17% - Intracommunautaire (-),taxes tag_lu_595,Taxe - Investissement Prestations 3% - Intracommunautaire (-),taxes tag_lu_596,Taxe - Investissement Prestations 6% - Intracommunautaire (-),taxes tag_lu_597,Taxe - Investissement Prestations 8% - Intracommunautaire (-),taxes -tag_lu_598,Base - Sans Taxe,taxes
\ No newline at end of file +tag_lu_598,Base - Sans Taxe,taxes diff --git a/addons/mail/models/ir_model.py b/addons/mail/models/ir_model.py index 5ab47dc0fc0..3a312cfe843 100644 --- a/addons/mail/models/ir_model.py +++ b/addons/mail/models/ir_model.py @@ -15,6 +15,12 @@ class IrModel(models.Model): help="Whether this model supports messages and notifications.", ) + def unlink(self): + # Delete followers for models that will be unlinked. + query = "DELETE FROM mail_followers WHERE res_model IN %s" + self.env.cr.execute(query, [tuple(self.mapped('model'))]) + return super(IrModel, self).unlink() + @api.multi def write(self, vals): if self and 'is_mail_thread' in vals: diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py index 86168bf6049..e7f487b6e6e 100644 --- a/addons/mail/models/mail_message.py +++ b/addons/mail/models/mail_message.py @@ -846,6 +846,9 @@ class Message(models.Model): ('channel_ids', 'in', email_channels.ids), ('email', '!=', self_sudo.author_id.email or self_sudo.email_from), ])._notify(self, force_send=force_send, send_after_commit=send_after_commit, user_signature=user_signature) + + notif_partners._notify_by_chat(self) + channels_sudo._notify(self) # Discard cache, because child / parent allow reading and therefore diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index 679f1726e75..263cfb84f08 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -2150,7 +2150,7 @@ class MailThread(models.AbstractModel): new_channels.setdefault(header_follower.channel_id.id, set()).add(new_subtype.id) # add followers coming from res.users relational fields that are tracked - to_add_users = self.env['res.users'].sudo().browse([values[name] for name in user_field_lst if values.get(name)]) + to_add_users = self.env['res.users'].sudo().browse([values[name] for name in user_field_lst if values.get(name)]).filtered(lambda u: u.partner_id.active) for partner in to_add_users.mapped('partner_id'): new_partners.setdefault(partner.id, None) diff --git a/addons/mail/static/src/js/many2many_tags_email.js b/addons/mail/static/src/js/many2many_tags_email.js index 84dcfa3ef0a..eaf0f89d546 100644 --- a/addons/mail/static/src/js/many2many_tags_email.js +++ b/addons/mail/static/src/js/many2many_tags_email.js @@ -127,6 +127,21 @@ var FieldMany2ManyTagsEmail = M2MTags.extend({ return _super.apply(self, arguments); }); }, + + /** + * Override for res.partner + * name_get is dynamic (based on context) while display_name is static + * (stored) + */ + get_render_data: function(ids){ + this.dataset.cancel_read(); + return this.dataset.name_get(ids).then(function (names) { + return _.map(names, function(name) { + return {id: name[0], display_name: name[1]}; + }); + }); + }, + }); field_registry.add('many2many_tags_email', FieldMany2ManyTagsEmail); diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py index cd399d60f67..14a288f5f94 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -161,7 +161,7 @@ class MrpProduction(models.Model): scrap_count = fields.Integer(compute='_compute_scrap_move_count', string='Scrap Move') priority = fields.Selection([('0', 'Not urgent'), ('1', 'Normal'), ('2', 'Urgent'), ('3', 'Very Urgent')], 'Priority', readonly=True, states={'confirmed': [('readonly', False)]}, default='1') - is_locked = fields.Boolean('Is Locked', default=True) + is_locked = fields.Boolean('Is Locked', default=True, copy=False) show_final_lots = fields.Boolean('Show Final Lots', compute='_compute_show_lots') production_location_id = fields.Many2one('stock.location', "Production Location", related='product_id.property_stock_production') @@ -216,8 +216,9 @@ class MrpProduction(models.Model): if order.bom_id.ready_to_produce == 'all_available': order.availability = any(move.state not in ('assigned', 'done', 'cancel') for move in order.move_raw_ids) and 'waiting' or 'assigned' else: - partial_list = [x.state in ('partially_available', 'assigned') for x in order.move_raw_ids] - assigned_list = [x.state in ('assigned', 'done', 'cancel') for x in order.move_raw_ids] + move_raw_ids = order.move_raw_ids.filtered(lambda m: m.product_qty) + partial_list = [x.state in ('partially_available', 'assigned') for x in move_raw_ids] + assigned_list = [x.state in ('assigned', 'done', 'cancel') for x in move_raw_ids] order.availability = (all(assigned_list) and 'assigned') or (any(partial_list) and 'partially_available') or 'waiting' @api.depends('move_raw_ids', 'is_locked', 'state', 'move_raw_ids.quantity_done') diff --git a/addons/mrp/models/mrp_routing.py b/addons/mrp/models/mrp_routing.py index fd1c36fee32..7c2c48c0fcd 100644 --- a/addons/mrp/models/mrp_routing.py +++ b/addons/mrp/models/mrp_routing.py @@ -22,7 +22,7 @@ class MrpRouting(models.Model): copy=True, oldname='workcenter_lines') location_id = fields.Many2one( 'stock.location', 'Production Location', - help="Keep empty if you produce at the location where you find the raw materials." + help="Keep empty if you produce at the location where you find the raw materials. " "Set a location if you produce at a fixed location. This can be a partner location " "if you subcontract the manufacturing operations.") company_id = fields.Many2one( @@ -49,8 +49,8 @@ class MrpRoutingWorkcenter(models.Model): routing_id = fields.Many2one( 'mrp.routing', 'Parent Routing', index=True, ondelete='cascade', required=True, - help="The routing contains all the Work Centers used and for how long. This will create work orders afterwards" - "which alters the execution of the manufacturing order. ") + help="The routing contains all the Work Centers used and for how long. This will create work orders afterwards " + "which alters the execution of the manufacturing order.") note = fields.Text('Description') company_id = fields.Many2one( 'res.company', 'Company', diff --git a/addons/mrp/models/mrp_workorder.py b/addons/mrp/models/mrp_workorder.py index eb5af9eedd3..90980117b29 100644 --- a/addons/mrp/models/mrp_workorder.py +++ b/addons/mrp/models/mrp_workorder.py @@ -389,9 +389,10 @@ class MrpWorkorder(models.Model): self.qty_producing = float_round(self.production_id.product_qty - self.qty_produced, precision_rounding=rounding) self._generate_lot_ids() + if self.next_work_order_id and self.production_id.product_id.tracking != 'none': + self.next_work_order_id._assign_default_final_lot_id() + if float_compare(self.qty_produced, self.production_id.product_qty, precision_rounding=rounding) >= 0: - if self.next_work_order_id and self.production_id.product_id.tracking != 'none': - self.next_work_order_id._assign_default_final_lot_id() self.button_finish() return True @@ -517,4 +518,4 @@ class MrpWorkorder(models.Model): @api.depends('qty_production', 'qty_produced') def _compute_qty_remaining(self): for wo in self: - wo.qty_remaining = float_round(wo.qty_production - wo.qty_produced, precision_rounding=wo.production_id.product_uom_id) + wo.qty_remaining = float_round(wo.qty_production - wo.qty_produced, precision_rounding=wo.production_id.product_uom_id.rounding) diff --git a/addons/mrp/models/stock_move.py b/addons/mrp/models/stock_move.py index 863b863c42c..97ae8d2458c 100644 --- a/addons/mrp/models/stock_move.py +++ b/addons/mrp/models/stock_move.py @@ -190,12 +190,18 @@ class StockMove(models.Model): def _generate_consumed_move_line(self, qty_to_add, final_lot, lot=False): if lot: - ml = self.move_line_ids.filtered(lambda ml: ml.lot_id == lot and not ml.lot_produced_id) + move_lines = self.move_line_ids.filtered(lambda ml: ml.lot_id == lot and not ml.lot_produced_id) else: - ml = self.move_line_ids.filtered(lambda ml: not ml.lot_id and not ml.lot_produced_id) - if ml: - new_quantity_done = (ml.qty_done + qty_to_add) - if new_quantity_done >= ml.product_uom_qty: + move_lines = self.move_line_ids.filtered(lambda ml: not ml.lot_id and not ml.lot_produced_id) + for ml in move_lines: + rounding = ml.product_uom_id.rounding + if float_compare(qty_to_add, 0, precision_rounding=rounding) <= 0: + break + quantity_to_process = min(qty_to_add, ml.product_uom_qty - ml.qty_done) + qty_to_add -= quantity_to_process + + new_quantity_done = (ml.qty_done + quantity_to_process) + if float_compare(new_quantity_done, ml.product_uom_qty, precision_rounding=rounding) >= 0: ml.write({'qty_done': new_quantity_done, 'lot_produced_id': final_lot.id}) else: new_qty_reserved = ml.product_uom_qty - new_quantity_done @@ -204,7 +210,8 @@ class StockMove(models.Model): 'lot_produced_id': final_lot.id} ml.copy(default=default) ml.with_context(bypass_reservation_update=True).write({'product_uom_qty': new_qty_reserved, 'qty_done': 0}) - else: + + if float_compare(qty_to_add, 0, precision_rounding=self.product_uom.rounding) > 0: vals = { 'move_id': self.id, 'product_id': self.product_id.id, diff --git a/addons/mrp/tests/test_order.py b/addons/mrp/tests/test_order.py index 296ae2fbe44..56b87b7e61d 100644 --- a/addons/mrp/tests/test_order.py +++ b/addons/mrp/tests/test_order.py @@ -276,7 +276,7 @@ class TestMrpOrder(TestMrpCommon): 'final_lot_id': finished_lot.id, 'qty_producing': 48 }) - + kit_wo.record_production() self.assertEqual(kit_wo.state, 'done', "Workorder should be in done state.") @@ -431,9 +431,9 @@ class TestMrpOrder(TestMrpCommon): """ In previous versions we had rounding and efficiency fields. We check if we can still do the same, but with only the rounding on the UoM""" self.product_6.uom_id.rounding = 1.0 bom_eff = self.env['mrp.bom'].create({'product_id': self.product_6.id, - 'product_tmpl_id': self.product_6.product_tmpl_id.id, - 'product_qty': 1, - 'product_uom_id': self.product_6.uom_id.id, + 'product_tmpl_id': self.product_6.product_tmpl_id.id, + 'product_qty': 1, + 'product_uom_id': self.product_6.uom_id.id, 'type': 'normal', 'bom_line_ids': [ (0, 0, {'product_id': self.product_2.id, 'product_qty': 2.03}), @@ -447,7 +447,7 @@ class TestMrpOrder(TestMrpCommon): #Check the production order has the right quantities self.assertEqual(production.move_raw_ids[0].product_qty, 41, 'The quantity should be rounded up') self.assertEqual(production.move_raw_ids[1].product_qty, 84, 'The quantity should be rounded up') - + # produce product produce_wizard = self.env['mrp.product.produce'].with_context({ 'active_id': production.id, @@ -516,3 +516,65 @@ class TestMrpOrder(TestMrpCommon): }).create({}) self.assertEqual(len(product_produce.produce_line_ids), 1, 'You should have 1 produce lines since one has already be consumed.') self.assertEqual(product_produce.produce_line_ids[0].lot_id, remaining_lot, 'Wrong lot proposed.') + + def test_product_produce_3(self): + """ Check that line are created when the consumed products are + tracked by serial and the lot proposed are correct. """ + self.stock_location = self.env.ref('stock.stock_location_stock') + self.stock_shelf_1 = self.env.ref('stock.stock_location_components') + self.stock_shelf_2 = self.env.ref('stock.stock_location_14') + mo, _, p_final, p1, p2 = self.generate_mo(tracking_base_1='lot', qty_base_1=10, qty_final=1) + self.assertEqual(len(mo), 1, 'MO should have been created') + + first_lot_for_p1 = self.env['stock.production.lot'].create({ + 'name': 'lot1', + 'product_id': p1.id, + }) + second_lot_for_p1 = self.env['stock.production.lot'].create({ + 'name': 'lot2', + 'product_id': p1.id, + }) + + final_product_lot = self.env['stock.production.lot'].create({ + 'name': 'lot1', + 'product_id': p_final.id, + }) + + self.env['stock.quant']._update_available_quantity(p1, self.stock_shelf_1, 3, lot_id=first_lot_for_p1) + self.env['stock.quant']._update_available_quantity(p1, self.stock_shelf_2, 3, lot_id=first_lot_for_p1) + self.env['stock.quant']._update_available_quantity(p1, self.stock_location, 8, lot_id=second_lot_for_p1) + self.env['stock.quant']._update_available_quantity(p2, self.stock_location, 5) + + mo.action_assign() + product_produce = self.env['mrp.product.produce'].with_context({ + 'active_id': mo.id, + 'active_ids': [mo.id], + }).create({ + 'product_qty': 1.0, + 'lot_id': final_product_lot.id, + }) + # product 1 lot 1 shelf1 + # product 1 lot 1 shelf2 + # product 1 lot 2 + self.assertEqual(len(product_produce.produce_line_ids), 3, 'You should have 3 produce lines. lot 1 shelf_1, lot 1 shelf_2, lot2') + + for produce_line in product_produce.produce_line_ids: + produce_line.qty_done = produce_line.qty_to_consume + 1 + product_produce.do_produce() + + move_1 = mo.move_raw_ids.filtered(lambda m: m.product_id == p1) + # qty_done/product_uom_qty lot + # 3/3 lot 1 shelf 1 + # 1/1 lot 1 shelf 2 + # 2/2 lot 1 shelf 2 + # 2/0 lot 1 other + # 5/4 lot 2 + ml_to_shelf_1 = move_1.move_line_ids.filtered(lambda ml: ml.lot_id == first_lot_for_p1 and ml.location_id == self.stock_shelf_1) + ml_to_shelf_2 = move_1.move_line_ids.filtered(lambda ml: ml.lot_id == first_lot_for_p1 and ml.location_id == self.stock_shelf_2) + + self.assertEqual(sum(ml_to_shelf_1.mapped('qty_done')), 3.0, '3 units should be took from shelf1 as reserved.') + self.assertEqual(sum(ml_to_shelf_2.mapped('qty_done')), 3.0, '3 units should be took from shelf2 as reserved.') + self.assertEqual(move_1.quantity_done, 13, 'You should have used the tem units.') + + mo.button_mark_done() + self.assertEqual(mo.state, 'done', "Production order should be in done state.") diff --git a/addons/mrp/tests/test_workorder_operation.py b/addons/mrp/tests/test_workorder_operation.py index 55edb1ff5da..79121e0468e 100644 --- a/addons/mrp/tests/test_workorder_operation.py +++ b/addons/mrp/tests/test_workorder_operation.py @@ -112,7 +112,6 @@ class TestWorkOrderProcess(common.TransactionCase): self.assertEqual(product_table_leg.qty_available, 19) self.assertEqual(product_table_sheet.qty_available, 19) - @unittest.skip('failing test: is up to date?') def test_00b_workorder_process(self): """ Testing consume quants and produced quants with workorder """ dining_table = self.env.ref("mrp.product_product_computer_desk") @@ -120,11 +119,18 @@ class TestWorkOrderProcess(common.TransactionCase): product_table_leg = self.env.ref('mrp.product_product_computer_desk_leg') product_bolt = self.env.ref('mrp.product_product_computer_desk_bolt') + bom = self.env['mrp.bom'].browse(self.ref("mrp.mrp_bom_desk")) + bom.routing_id = self.ref('mrp.mrp_routing_1') + + bom.bom_line_ids.filtered(lambda p: p.product_id == product_table_sheet).operation_id = bom.routing_id.operation_ids[0] + bom.bom_line_ids.filtered(lambda p: p.product_id == product_table_leg).operation_id = bom.routing_id.operation_ids[1] + bom.bom_line_ids.filtered(lambda p: p.product_id == product_bolt).operation_id = bom.routing_id.operation_ids[2] + production_table = self.env['mrp.production'].create({ 'product_id': dining_table.id, 'product_qty': 2.0, 'product_uom_id': dining_table.uom_id.id, - 'bom_id': self.ref("mrp.mrp_bom_desk") + 'bom_id': bom.id, }) # Set tracking lot on finish and consume products. @@ -185,11 +191,11 @@ class TestWorkOrderProcess(common.TransactionCase): finished_lot = self.env['stock.production.lot'].create({'product_id': production_table.product_id.id}) workorders[0].write({'final_lot_id': finished_lot.id, 'qty_producing': 1.0}) workorders[0].button_start() - workorders[0].active_move_lot_ids[0].write({'lot_id': lot_sheet.id, 'quantity_done': 1}) + workorders[0].active_move_line_ids[0].write({'lot_id': lot_sheet.id, 'qty_done': 1}) self.assertEqual(workorders[0].state, 'progress') workorders[0].record_production() - move_table_sheet = production_table.move_raw_ids.filtered(lambda x : x.product_id == product_table_sheet) + move_table_sheet = production_table.move_raw_ids.filtered(lambda p: p.product_id == product_table_sheet) self.assertEqual(move_table_sheet.quantity_done, 1) # -------------------------------------------------------------- @@ -197,9 +203,9 @@ class TestWorkOrderProcess(common.TransactionCase): # --------------------------------------------------------- workorders[1].button_start() workorders[1].qty_producing = 1.0 - workorders[1].active_move_lot_ids[0].write({'lot_id': lot_leg.id, 'quantity_done': 4}) + workorders[1].active_move_line_ids[0].write({'lot_id': lot_leg.id, 'qty_done': 4}) workorders[1].record_production() - move_leg = production_table.move_raw_ids.filtered(lambda x : x.product_id == product_table_leg) + move_leg = production_table.move_raw_ids.filtered(lambda p: p.product_id == product_table_leg) #self.assertEqual(workorders[1].state, 'done') self.assertEqual(move_leg.quantity_done, 4) @@ -208,9 +214,9 @@ class TestWorkOrderProcess(common.TransactionCase): # --------------------------------------------------------- workorders[2].button_start() workorders[2].qty_producing = 1.0 - move_lot = workorders[2].active_move_lot_ids[0] - move_lot.write({'lot_id': lot_bolt.id, 'quantity_done': 4}) - move_table_bolt = production_table.move_raw_ids.filtered(lambda x : x.product_id.id == product_bolt.id) + move_lot = workorders[2].active_move_line_ids[0] + move_lot.write({'lot_id': lot_bolt.id, 'qty_done': 4}) + move_table_bolt = production_table.move_raw_ids.filtered(lambda p: p.product_id.id == product_bolt.id) workorders[2].record_production() self.assertEqual(move_table_bolt.quantity_done, 4) @@ -222,9 +228,9 @@ class TestWorkOrderProcess(common.TransactionCase): # Check consume quants and produce quants after posting inventory # --------------------------------------------------------------- production_table.post_inventory() - self.assertEqual(sum(move_table_sheet.quant_ids.mapped('qty')), 1, "Wrong quantity of consumed product %s" % move_table_sheet.product_id.name) - self.assertEqual(sum(move_leg.quant_ids.mapped('qty')), 4, "Wrong quantity of consumed product %s" % move_leg.product_id.name) - self.assertEqual(sum(move_table_bolt.quant_ids.mapped('qty')), 4, "Wrong quantity of consumed product %s" % move_table_bolt.product_id.name) + self.assertEqual(sum(move_table_sheet.mapped('quantity_done')), 1, "Wrong quantity of consumed product %s" % move_table_sheet.product_id.name) + self.assertEqual(sum(move_leg.mapped('quantity_done')), 4, "Wrong quantity of consumed product %s" % move_leg.product_id.name) + self.assertEqual(sum(move_table_bolt.mapped('quantity_done')), 4, "Wrong quantity of consumed product %s" % move_table_bolt.product_id.name) def test_01_without_workorder(self): """ Testing consume quants and produced quants without workorder """ diff --git a/addons/mrp/views/mrp_workcenter_views.xml b/addons/mrp/views/mrp_workcenter_views.xml index d4754003111..baadfaf9fb8 100644 --- a/addons/mrp/views/mrp_workcenter_views.xml +++ b/addons/mrp/views/mrp_workcenter_views.xml @@ -125,9 +125,11 @@ <field name="name">Work Orders Performance</field> <field name="type">ir.actions.act_window</field> <field name="res_model">mrp.workorder</field> - <field name="domain">[('state','=','done')]</field> + <field name="domain">[]</field> + <field name="context">{'search_default_done': True}</field> <field name="view_type">form</field> <field name="view_mode">graph,pivot,tree,form,gantt</field> + <field name="search_view_id" ref="view_mrp_production_work_order_search"/> </record> <!-- Workcenter Kanban view--> diff --git a/addons/mrp/views/mrp_workorder_views.xml b/addons/mrp/views/mrp_workorder_views.xml index 521e861341a..07d82b3626e 100644 --- a/addons/mrp/views/mrp_workorder_views.xml +++ b/addons/mrp/views/mrp_workorder_views.xml @@ -200,7 +200,7 @@ <tree> <field name="date_start"/> <field name="date_end"/> - <field name="duration" widget="float_time"/> + <field name="duration" widget="float_time" sum="Total duration"/> <field name="user_id"/> <field name="loss_id" string="Efficiency"/> </tree> diff --git a/addons/pad_project/views/project_views.xml b/addons/pad_project/views/project_views.xml index c35bd4225ee..e97514a9859 100644 --- a/addons/pad_project/views/project_views.xml +++ b/addons/pad_project/views/project_views.xml @@ -6,7 +6,7 @@ <field name="inherit_id" ref="project.view_task_form2"/> <field name="arch" type="xml"> <xpath expr="//field[@name='description']" position="attributes"> - <attribute name="attrs">{'invisible': [('use_pad', '=', True)]}</attribute> + <attribute name="attrs">{'invisible': [('use_pad', '=', True)], 'readonly': [('use_pad', '=', True)]}</attribute> </xpath> <field name="description" position="after"> <field name="use_pad" invisible="1"/> diff --git a/addons/payment/models/payment_acquirer.py b/addons/payment/models/payment_acquirer.py index 7255c3e56a9..d20de8a90df 100644 --- a/addons/payment/models/payment_acquirer.py +++ b/addons/payment/models/payment_acquirer.py @@ -121,7 +121,7 @@ class PaymentAcquirer(models.Model): ('ask', 'Let the customer decide'), ('always', 'Always')], string='Save Cards', default='none', - help="This option allows customers to save their credit card as a payment token and to reuse it for a later purchase." + help="This option allows customers to save their credit card as a payment token and to reuse it for a later purchase. " "If you manage subscriptions (recurring invoicing), you need it to automatically charge the customer when you " "issue an invoice.") token_implemented = fields.Boolean('Saving Card Data supported', compute='_compute_feature_support', search='_search_is_tokenized') @@ -418,14 +418,9 @@ class PaymentAcquirer(models.Model): # TDE FIXME: remove that brol if self.module_id and self.module_state != 'installed': self.module_id.button_immediate_install() - context = dict(self._context, active_id=self.ids[0]) return { - 'view_type': 'form', - 'view_mode': 'form', - 'res_model': 'payment.acquirer', - 'type': 'ir.actions.act_window', - 'res_id': self.ids[0], - 'context': context, + 'type': 'ir.actions.client', + 'tag': 'reload', } class PaymentIcon(models.Model): diff --git a/addons/payment/static/src/js/payment_form.js b/addons/payment/static/src/js/payment_form.js index c65dc7cce57..5cfe9b1d88a 100644 --- a/addons/payment/static/src/js/payment_form.js +++ b/addons/payment/static/src/js/payment_form.js @@ -132,7 +132,7 @@ odoo.define('payment.payment_form', function (require) { // if there's a prepare tx url set if ($tx_url.length === 1) { // if the user wants to save his credit card info - var form_save_token = $('input[name="o_payment_form_save_token"]').checked === true; + var form_save_token = $('input[name="o_payment_form_save_token"]').prop('checked'); // then we call the route to prepare the transaction ajax.jsonRpc($tx_url[0].value, 'call', { 'acquirer_id': parseInt(acquirer_id), @@ -410,14 +410,18 @@ odoo.define('payment.payment_form', function (require) { }, }); - require('web.dom_ready'); // only start this when dom is ready - if (!$('.o_payment_form').length) { - return $.Deferred().reject("DOM doesn't contain '.o_payment_form'"); - } - $('.o_payment_form').each(function () { - var $elem = $(this); - var form = new PaymentForm(null, $elem.data()); - form.attachTo($elem); + $(function () { + // TODO move this to another module, requiring dom_ready and rejecting + // the returned deferred to get the proper message + if (!$('.o_payment_form').length) { + console.log("DOM doesn't contain '.o_payment_form'"); + return; + } + $('.o_payment_form').each(function () { + var $elem = $(this); + var form = new PaymentForm(null, $elem.data()); + form.attachTo($elem); + }); }); return PaymentForm; diff --git a/addons/payment/views/payment_templates.xml b/addons/payment/views/payment_templates.xml index c601d945261..772c970dc52 100644 --- a/addons/payment/views/payment_templates.xml +++ b/addons/payment/views/payment_templates.xml @@ -147,7 +147,7 @@ <i class="fa fa-lock"> Pay</i> </t> </button> - <button t-if="mode == 'manage'" id="o_payment_form_add_pm" class="btn btn-primary btn-lg mb8 mt8"> + <button t-if="mode == 'manage' and s2s_acquirers" id="o_payment_form_add_pm" class="btn btn-primary btn-lg mb8 mt8"> <i class="fa fa-plus-circle"> Add new card</i> </button> </div> diff --git a/addons/payment_authorize/models/authorize_request.py b/addons/payment_authorize/models/authorize_request.py index c6a912de5d2..18c535aedcd 100644 --- a/addons/payment_authorize/models/authorize_request.py +++ b/addons/payment_authorize/models/authorize_request.py @@ -165,6 +165,22 @@ class AuthorizeAPI(): etree.SubElement(creditCard, "cardCode").text = card_code etree.SubElement(root, "validationMode").text = 'liveMode' response = self._authorize_request(root) + + # If the user didn't set up authorize.net properly then the response + # won't contain stuff like customerProfileId and accessing text + # will raise a NoneType has no text attribute + msg = response.find('messages') + if msg is not None: + rc = msg.find('resultCode') + if rc is not None and rc.text == 'Error': + err = msg.find('message') + err_code = err.find('code').text + err_msg = err.find('text').text + raise UserError( + "Authorize.net Error:\nCode: %s\nMessage: %s" + % (err_code, err_msg) + ) + res = dict() res['profile_id'] = response.find('customerProfileId').text res['payment_profile_id'] = response.find('customerPaymentProfileIdList/numericString').text diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js index d24600ba17f..14b9285f38d 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -207,7 +207,7 @@ exports.PosModel = Backbone.Model.extend({ },{ model: 'account.tax', fields: ['name','amount', 'price_include', 'include_base_amount', 'amount_type', 'children_tax_ids'], - domain: null, + domain: function(self) {return [['company_id', '=', self.company && self.company.id || false]]}, loaded: function(self, taxes){ self.taxes = taxes; self.taxes_by_id = {}; diff --git a/addons/point_of_sale/static/src/js/screens.js b/addons/point_of_sale/static/src/js/screens.js index 088f62fbeeb..61e72258997 100644 --- a/addons/point_of_sale/static/src/js/screens.js +++ b/addons/point_of_sale/static/src/js/screens.js @@ -2030,6 +2030,7 @@ var PaymentScreenWidget = ScreenWidget.extend({ } order.initialize_validation_date(); + order.finalized = true; if (order.is_to_invoice()) { var invoiced = this.pos.push_and_invoice_order(order); diff --git a/addons/point_of_sale/views/pos_config_view.xml b/addons/point_of_sale/views/pos_config_view.xml index b6a690d4b75..f76677d5709 100644 --- a/addons/point_of_sale/views/pos_config_view.xml +++ b/addons/point_of_sale/views/pos_config_view.xml @@ -86,7 +86,7 @@ </div> </div> </div> - <div class="col-xs-12 col-md-6 o_setting_box" title="Don’t turn this option on if you take orders on smartphones or tablets. Such devices already benefit from a native keyboard."> + <div class="col-xs-12 col-md-6 o_setting_box" title="Don't turn this option on if you take orders on smartphones or tablets. Such devices already benefit from a native keyboard."> <div class="o_setting_left_pane"> <field name="iface_vkeyboard"/> </div> diff --git a/addons/product/models/product_pricelist.py b/addons/product/models/product_pricelist.py index ce9aec95e43..3df96254141 100644 --- a/addons/product/models/product_pricelist.py +++ b/addons/product/models/product_pricelist.py @@ -108,7 +108,7 @@ class Pricelist(models.Model): """ self.ensure_one() if not date: - date = self._context.get('date') or fields.Date.today() + date = self._context.get('date') or fields.Date.context_today(self) if not uom_id and self._context.get('uom'): uom_id = self._context['uom'] if uom_id: diff --git a/addons/project_timesheet_holidays/models/res_company.py b/addons/project_timesheet_holidays/models/res_company.py index 3cca96aff82..0eb9e4e3686 100644 --- a/addons/project_timesheet_holidays/models/res_company.py +++ b/addons/project_timesheet_holidays/models/res_company.py @@ -40,6 +40,7 @@ class Company(models.Model): 'name': _('Leaves'), 'project_id': company.leave_timesheet_project_id.id, 'active': False, + 'company_id': False, }) company.write({ 'leave_timesheet_task_id': task.id, diff --git a/addons/purchase/controllers/portal.py b/addons/purchase/controllers/portal.py index 4a169daba44..53a517845e6 100644 --- a/addons/purchase/controllers/portal.py +++ b/addons/purchase/controllers/portal.py @@ -15,7 +15,7 @@ class CustomerPortal(CustomerPortal): def _prepare_portal_layout_values(self): values = super(CustomerPortal, self)._prepare_portal_layout_values() partner = request.env.user.partner_id - values['purchase_count'] = request.env['purchase.order'].search_count([ + values['purchase_count'] = request.env['purchase.order'].sudo().search_count([ '|', ('message_partner_ids', 'child_of', [partner.commercial_partner_id.id]), ('partner_id', 'child_of', [partner.commercial_partner_id.id]), diff --git a/addons/purchase/tests/test_stockvaluation.py b/addons/purchase/tests/test_stockvaluation.py index 09359417738..0d30ed9454c 100644 --- a/addons/purchase/tests/test_stockvaluation.py +++ b/addons/purchase/tests/test_stockvaluation.py @@ -167,7 +167,7 @@ class TestStockValuation(TransactionCase): move1 = picking1.move_lines[0] # convert the price unit in the company currency - price_unit_usd = po1.currency_id.compute(po1.order_line.price_unit, po1.company_id.currency_id, round=False) + price_unit_usd = po1.currency_id.compute(po1.order_line.price_unit, po1.company_id.currency_id, round=True) # the unit price of the move is the unit price of the purchase order line converted in # the company's currency @@ -181,7 +181,7 @@ class TestStockValuation(TransactionCase): 'company_id': po1.company_id.id, }) eur_currency._compute_current_rate() - price_unit_usd_new_rate = po1.currency_id.compute(po1.order_line.price_unit, po1.company_id.currency_id, round=False) + price_unit_usd_new_rate = po1.currency_id.compute(po1.order_line.price_unit, po1.company_id.currency_id, round=True) # the new price_unit is lower than th initial because of the rate's change self.assertLess(price_unit_usd_new_rate, price_unit_usd) diff --git a/addons/purchase/views/purchase_views.xml b/addons/purchase/views/purchase_views.xml index 8d450ca1e91..3b31c1284e3 100644 --- a/addons/purchase/views/purchase_views.xml +++ b/addons/purchase/views/purchase_views.xml @@ -213,6 +213,7 @@ <field name="sequence" widget="handle"/> <field name="product_id" attrs="{'readonly': [('state', 'in', ('purchase', 'to approve','done', 'cancel'))]}" context="{'partner_id':parent.partner_id, 'quantity':product_qty,'uom':product_uom, 'company_id': parent.company_id}" force_save="1"/> <field name="name"/> + <field name="move_dest_ids" invisible="1"/> <field name="date_planned"/> <field name="company_id" groups="base.group_multi_company" options="{'no_create': True}"/> <field name="account_analytic_id" context="{'default_partner_id':parent.partner_id}" groups="purchase.group_analytic_accounting"/> diff --git a/addons/resource/models/resource.py b/addons/resource/models/resource.py index 27be5fa9992..2506a819c74 100644 --- a/addons/resource/models/resource.py +++ b/addons/resource/models/resource.py @@ -722,7 +722,7 @@ class ResourceCalendarLeaves(models.Model): date_to = fields.Datetime('End Date', required=True) tz = fields.Selection( _tz_get, string='Timezone', default=lambda self: self._context.get('tz', self.env.user.tz or 'UTC'), - help="Timezone used when encoding the leave. It is used to correctly" + help="Timezone used when encoding the leave. It is used to correctly " "localize leave hours when computing time intervals.") resource_id = fields.Many2one( "resource.resource", 'Resource', diff --git a/addons/sale_mrp/models/sale_mrp.py b/addons/sale_mrp/models/sale_mrp.py index fe789333d03..adbcebd4f18 100644 --- a/addons/sale_mrp/models/sale_mrp.py +++ b/addons/sale_mrp/models/sale_mrp.py @@ -76,7 +76,7 @@ class AccountInvoiceLine(models.Model): prod_moves = [m for m in moves if m.product_id.id == product_id] prod_qty_done = factor * qty_done prod_quantity = factor * quantity - average_price_unit += self._compute_average_price(prod_qty_done, prod_quantity, prod_moves) + average_price_unit += factor * self._compute_average_price(prod_qty_done, prod_quantity, prod_moves) price_unit = average_price_unit or price_unit price_unit = self.product_id.uom_id._compute_price(price_unit, self.uom_id) return price_unit diff --git a/addons/sales_team/data/sales_team_demo.xml b/addons/sales_team/data/sales_team_demo.xml index f9a6ff2c52d..91ea15a5470 100644 --- a/addons/sales_team/data/sales_team_demo.xml +++ b/addons/sales_team/data/sales_team_demo.xml @@ -5,6 +5,7 @@ <record model="crm.team" id="team_sales_department"> <field name="name">Europe</field> <field name="dashboard_graph_group">week</field> + <field name="company_id" eval="False"/> </record> <record id="base.user_demo" model="res.users"> <field name="groups_id" eval="[(4,ref('sales_team.group_sale_salesman'))]"/> @@ -17,6 +18,7 @@ <record model="crm.team" id="crm_team_1"> <field name="name">America</field> <field name="dashboard_graph_group">week</field> + <field name="company_id" eval="False"/> </record> </data> </odoo> diff --git a/addons/stock/models/stock_inventory.py b/addons/stock/models/stock_inventory.py index f024d28367b..0544501cb11 100644 --- a/addons/stock/models/stock_inventory.py +++ b/addons/stock/models/stock_inventory.py @@ -184,7 +184,7 @@ class Inventory(models.Model): }) def action_start(self): - for inventory in self: + for inventory in self.filtered(lambda x: x.state not in ('done','cancel')): vals = {'state': 'confirm', 'date': fields.Datetime.now()} if (inventory.filter != 'partial') and not inventory.line_ids: vals.update({'line_ids': [(0, 0, line_values) for line_values in inventory._get_inventory_lines_values()]}) @@ -383,9 +383,9 @@ class InventoryLine(models.Model): ('prod_lot_id', '=', values.get('prod_lot_id'))]) res = super(InventoryLine, self).create(values) if existings: - raise UserError(_("You cannot have two inventory adjustements in state 'in Progress' with the same product" - "(%s), same location(%s), same package, same owner and same lot. Please first validate" - "the first inventory adjustement with this product before creating another one.") % (res.product_id.name, res.location_id.name)) + raise UserError(_("You cannot have two inventory adjustements in state 'in Progress' with the same product " + "(%s), same location (%s), same package, same owner and same lot. Please first validate " + "the first inventory adjustement with this product before creating another one.") % (res.product_id.display_name, res.location_id.name)) return res @api.constrains('product_id') diff --git a/addons/stock/models/stock_location.py b/addons/stock/models/stock_location.py index 9a458f01b30..05e4bb2b60d 100644 --- a/addons/stock/models/stock_location.py +++ b/addons/stock/models/stock_location.py @@ -208,7 +208,7 @@ class PushedFlow(models.Model): ('manual', 'Manual Operation'), ('transparent', 'Automatic No Step Added')], string='Automatic Move', default='manual', index=True, required=True, - help="The 'Manual Operation' value will create a stock move after the current one." + help="The 'Manual Operation' value will create a stock move after the current one. " "With 'Automatic No Step Added', the location is replaced in the original move.") propagate = fields.Boolean('Propagate cancel and split', default=True, help='If checked, when the previous move is cancelled or split, the move generated by this move will too') active = fields.Boolean('Active', default=True) diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index 979a598bcae..12dc65b6b69 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -115,9 +115,9 @@ class StockMove(models.Model): ('make_to_stock', 'Default: Take From Stock'), ('make_to_order', 'Advanced: Apply Procurement Rules')], string='Supply Method', default='make_to_stock', required=True, - help="By default, the system will take from the stock in the source location and passively wait for availability." + help="By default, the system will take from the stock in the source location and passively wait for availability. " "The other possibility allows you to directly create a procurement on the source location (and thus ignore " - "its current stock) to gather products. If we want to chain moves and have this one to wait for the previous," + "its current stock) to gather products. If we want to chain moves and have this one to wait for the previous, " "this second option should be chosen.") scrapped = fields.Boolean('Scrapped', related='location_dest_id.scrap_location', readonly=True, store=True) scrap_ids = fields.One2many('stock.scrap', 'move_id') diff --git a/addons/stock/models/stock_move_line.py b/addons/stock/models/stock_move_line.py index ba9383473ef..d2025ebe373 100644 --- a/addons/stock/models/stock_move_line.py +++ b/addons/stock/models/stock_move_line.py @@ -473,7 +473,6 @@ class StockMoveLine(models.Model): ('owner_id', '=', owner_id.id if owner_id else False), ('package_id', '=', package_id.id if package_id else False), ('product_qty', '>', 0.0), - ('qty_done', '=', 0.0), ('id', '!=', self.id), ] oudated_candidates = self.env['stock.move.line'].search(oudated_move_lines_domain) @@ -487,7 +486,10 @@ class StockMoveLine(models.Model): if float_compare(candidate.product_qty, quantity, precision_rounding=rounding) <= 0: quantity -= candidate.product_qty move_to_recompute_state |= candidate.move_id - candidate.unlink() + if candidate.qty_done: + candidate.product_uom_qty = 0.0 + else: + candidate.unlink() else: # split this move line and assign the new part to our extra move quantity_split = float_round( diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py index cfb4995fccb..8b45370f9a5 100644 --- a/addons/stock/models/stock_picking.py +++ b/addons/stock/models/stock_picking.py @@ -407,7 +407,7 @@ class Picking(models.Model): packages = self.env['stock.quant.package'] for ml in picking.move_line_ids: if ml.package_id.id == ml.result_package_id.id: - if picking._check_move_lines_map_quant_package(ml.package_id): + if picking.state in ('done', 'cancel') or picking._check_move_lines_map_quant_package(ml.package_id): packages |= ml.package_id picking.entire_package_ids = packages picking.entire_package_detail_ids = packages @@ -542,7 +542,19 @@ class Picking(models.Model): # call `_action_assign` on every confirmed move which location_id bypasses the reservation self.filtered(lambda picking: picking.location_id.usage in ('supplier', 'inventory', 'production') and picking.state == 'confirmed')\ .mapped('move_lines')._action_assign() - return True + if self.env.context.get('planned_picking') and len(self) == 1: + action = self.env.ref('stock.action_picking_form') + result = action.read()[0] + result['res_id'] = self.id + result['context'] = { + 'search_default_picking_type_id': [self.picking_type_id.id], + 'default_picking_type_id': self.picking_type_id.id, + 'contact_display': 'partner_address', + 'planned_picking': False, + } + return result + else: + return True @api.multi def action_assign(self): @@ -654,10 +666,8 @@ class Picking(models.Model): @api.multi def do_unreserve(self): - for move in self: - for move_line in move.move_lines: - move_line._do_unreserve() - self.write({'state': 'confirmed'}) + for picking in self: + picking.move_lines._do_unreserve() @api.multi def button_validate(self): diff --git a/addons/stock/tests/test_move.py b/addons/stock/tests/test_move.py index 9fd8088ca38..39394a749fa 100644 --- a/addons/stock/tests/test_move.py +++ b/addons/stock/tests/test_move.py @@ -714,6 +714,48 @@ class StockMove(TransactionCase): self.assertEqual(move1.state, 'assigned') self.assertEqual(move1.reserved_availability, 1.0) + def test_availability_4(self): + self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 30.0) + move1 = self.env['stock.move'].create({ + 'name': 'test_availability_4', + 'location_id': self.stock_location.id, + 'location_dest_id': self.customer_location.id, + 'product_id': self.product1.id, + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 15.0, + }) + move1._action_confirm() + move1._action_assign() + self.assertEqual(move1.state, 'assigned') + + move2 = self.env['stock.move'].create({ + 'name': 'test_availability_4', + 'location_id': self.stock_location.id, + 'location_dest_id': self.customer_location.id, + 'product_id': self.product1.id, + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 15.0, + }) + move2._action_confirm() + move2._action_assign() + + # set 15 as quantity done for the first and 30 as the second + move1.move_line_ids.qty_done = 15 + move2.move_line_ids.qty_done = 30 + + # validate the second, the first should be unreserved + move2._action_done() + + self.assertEqual(move1.state, 'confirmed') + self.assertEqual(move1.move_line_ids.qty_done, 15) + self.assertEqual(move2.state, 'done') + + stock_quants = self.env['stock.quant']._gather(self.product1, self.stock_location) + self.assertEqual(len(stock_quants), 0) + customer_quants = self.env['stock.quant']._gather(self.product1, self.customer_location) + self.assertEqual(customer_quants.quantity, 30) + self.assertEqual(customer_quants.reserved_quantity, 0) + def test_unreserve_1(self): """ Check that unreserving a stock move sets the products reserved as available and set the state back to confirmed. diff --git a/addons/stock/tests/test_move2.py b/addons/stock/tests/test_move2.py index 43a92522339..a3a6b0ae46b 100644 --- a/addons/stock/tests/test_move2.py +++ b/addons/stock/tests/test_move2.py @@ -576,6 +576,7 @@ class TestPickShip(TestStockCommon): picking_pack.button_validate() self.assertEqual(first_pack.location_id, output_sub_location) self.assertEqual(second_pack.location_id, output_sub_location) + self.assertEqual(len(picking_pack.entire_package_ids), 2) picking_ship.action_assign() self.assertEqual(picking_ship.move_line_ids[:1].location_id, output_sub_location) for move_line in picking_ship.move_line_ids: @@ -647,6 +648,23 @@ class TestPickShip(TestStockCommon): self.assertEqual(picking_client.move_lines.state, 'confirmed', 'The move should be confirmed since all the origin moves are processed.') self.assertEqual(picking_client.state, 'confirmed', 'The picking should be confirmed since all the moves are confirmed.') + def test_unreserve(self): + picking_pick, picking_client = self.create_pick_ship() + + self.assertEqual(picking_pick.state, 'confirmed') + picking_pick.do_unreserve() + self.assertEqual(picking_pick.state, 'confirmed') + location = self.env['stock.location'].browse(self.stock_location) + self.env['stock.quant']._update_available_quantity(self.productA, location, 10.0) + picking_pick.action_assign() + self.assertEqual(picking_pick.state, 'assigned') + picking_pick.do_unreserve() + self.assertEqual(picking_pick.state, 'confirmed') + + self.assertEqual(picking_client.state, 'waiting') + picking_client.do_unreserve() + self.assertEqual(picking_client.state, 'waiting') + class TestSinglePicking(TestStockCommon): def test_backorder_1(self): diff --git a/addons/stock/views/stock_picking_views.xml b/addons/stock/views/stock_picking_views.xml index c6343057f2a..82b74d5f32e 100644 --- a/addons/stock/views/stock_picking_views.xml +++ b/addons/stock/views/stock_picking_views.xml @@ -273,7 +273,7 @@ <field name="is_initial_demand_editable" invisible="1"/> <field name="product_uom_qty" attrs="{'readonly': ['|', ('is_initial_demand_editable', '=', False), ('in_entire_package', '=', True)], 'required': [('product_id', '!=', False)]}"/> <field name="is_locked" invisible="1"/> - <field name="qty_done" attrs="{'readonly': ['|', ('state', 'in', ('done', 'cancel')), ('is_locked', '=', True), ('in_entire_package', '=', True)]}"/> + <field name="qty_done" attrs="{'readonly': ['|', ('state', 'in', ('done', 'cancel')), ('is_locked', '=', True), ('in_entire_package', '=', True)]}" force_save="1"/> </tree> </field> <field name="entire_package_detail_ids" context="{'picking_id': id, 'tree_view_ref': 'stock.view_quant_package_picking_tree'}" attrs="{'invisible': ['|', ('picking_type_entire_packs', '=', False), ('show_operations', '=', False)]}" readonly="0"/> diff --git a/addons/stock/wizard/stock_picking_return.py b/addons/stock/wizard/stock_picking_return.py index dfd07fe93aa..4a6b8094aba 100644 --- a/addons/stock/wizard/stock_picking_return.py +++ b/addons/stock/wizard/stock_picking_return.py @@ -4,6 +4,7 @@ from odoo import api, fields, models, _ from odoo.addons import decimal_precision as dp from odoo.exceptions import UserError +from odoo.tools.float_utils import float_round class ReturnPickingLine(models.TransientModel): @@ -12,6 +13,7 @@ class ReturnPickingLine(models.TransientModel): product_id = fields.Many2one('product.product', string="Product", required=True, domain="[('id', '=', product_id)]") quantity = fields.Float("Quantity", digits=dp.get_precision('Product Unit of Measure'), required=True) + uom_id = fields.Many2one('product.uom', string='Unit of Measure', related='move_id.product_uom') wizard_id = fields.Many2one('stock.return.picking', string="Wizard") move_id = fields.Many2one('stock.move', "Move") @@ -49,7 +51,8 @@ class ReturnPicking(models.TransientModel): move_dest_exists = True quantity = move.product_qty - sum(move.move_dest_ids.filtered(lambda m: m.state in ['partially_available', 'assigned', 'done']).\ mapped('move_line_ids').mapped('product_qty')) - product_return_moves.append((0, 0, {'product_id': move.product_id.id, 'quantity': quantity, 'move_id': move.id})) + quantity = float_round(quantity, precision_rounding=move.product_uom.rounding) + product_return_moves.append((0, 0, {'product_id': move.product_id.id, 'quantity': quantity, 'move_id': move.id, 'uom_id': move.product_id.uom_id.id})) if not product_return_moves: raise UserError(_("No products to return (only lines in Done state and not fully returned yet can be returned)!")) @@ -72,6 +75,7 @@ class ReturnPicking(models.TransientModel): vals = { 'product_id': return_line.product_id.id, 'product_uom_qty': return_line.quantity, + 'product_uom': return_line.product_id.uom_id.id, 'picking_id': new_picking.id, 'state': 'draft', 'location_id': return_line.move_id.location_dest_id.id, diff --git a/addons/stock/wizard/stock_picking_return_views.xml b/addons/stock/wizard/stock_picking_return_views.xml index b7f15e73dbb..797eab283c3 100644 --- a/addons/stock/wizard/stock_picking_return_views.xml +++ b/addons/stock/wizard/stock_picking_return_views.xml @@ -20,6 +20,7 @@ <tree editable="top" create="0"> <field name="product_id" options="{'no_create': True, 'no_open': True}" force_save="1"/> <field name="quantity"/> + <field name="uom_id" readonly="1" groups="product.group_uom"/> </tree> </field> </group> diff --git a/addons/stock_account/models/stock.py b/addons/stock_account/models/stock.py index e35cf5d9c64..0c1fdf09cb3 100644 --- a/addons/stock_account/models/stock.py +++ b/addons/stock_account/models/stock.py @@ -184,7 +184,7 @@ class StockMove(models.Model): :return: True if the move is entering the company else False """ - for move_line in self.move_line_ids: + for move_line in self.move_line_ids.filtered(lambda ml: not ml.owner_id): if not move_line.location_id._should_be_valued() and move_line.location_dest_id._should_be_valued(): return True return False @@ -195,7 +195,7 @@ class StockMove(models.Model): :return: True if the move is leaving the company else False """ - for move_line in self.move_line_ids: + for move_line in self.move_line_ids.filtered(lambda ml: not ml.owner_id): if move_line.location_id._should_be_valued() and not move_line.location_dest_id._should_be_valued(): return True return False @@ -204,7 +204,7 @@ class StockMove(models.Model): def _run_fifo(self, move, quantity=None): move.ensure_one() # Find back incoming stock moves (called candidates here) to value this move. - valued_move_lines = move.move_line_ids.filtered(lambda ml: ml.location_id._should_be_valued() and not ml.location_dest_id._should_be_valued()) + valued_move_lines = move.move_line_ids.filtered(lambda ml: ml.location_id._should_be_valued() and not ml.location_dest_id._should_be_valued() and not ml.owner_id) valued_quantity = 0 for valued_move_line in valued_move_lines: valued_quantity += valued_move_line.product_uom_id._compute_quantity(valued_move_line.qty_done, move.product_id.uom_id) @@ -263,7 +263,7 @@ class StockMove(models.Model): def _run_valuation(self, quantity=None): self.ensure_one() if self._is_in(): - valued_move_lines = self.move_line_ids.filtered(lambda ml: not ml.location_id._should_be_valued() and ml.location_dest_id._should_be_valued()) + valued_move_lines = self.move_line_ids.filtered(lambda ml: not ml.location_id._should_be_valued() and ml.location_dest_id._should_be_valued() and not ml.owner_id) valued_quantity = 0 for valued_move_line in valued_move_lines: valued_quantity += valued_move_line.product_uom_id._compute_quantity(valued_move_line.qty_done, self.product_id.uom_id) @@ -288,7 +288,7 @@ class StockMove(models.Model): }) self.write(vals) elif self._is_out(): - valued_move_lines = self.move_line_ids.filtered(lambda ml: ml.location_id._should_be_valued() and not ml.location_dest_id._should_be_valued()) + valued_move_lines = self.move_line_ids.filtered(lambda ml: ml.location_id._should_be_valued() and not ml.location_dest_id._should_be_valued() and not ml.owner_id) valued_quantity = sum(valued_move_lines.mapped('qty_done')) self.env['stock.move']._run_fifo(self, quantity=quantity) if self.product_id.cost_method in ['standard', 'average']: diff --git a/addons/stock_account/tests/test_stockvaluation.py b/addons/stock_account/tests/test_stockvaluation.py index f7e63cc9f15..ec14930dbdc 100644 --- a/addons/stock_account/tests/test_stockvaluation.py +++ b/addons/stock_account/tests/test_stockvaluation.py @@ -12,6 +12,7 @@ class TestStockValuation(TransactionCase): self.customer_location = self.env.ref('stock.stock_location_customers') self.supplier_location = self.env.ref('stock.stock_location_suppliers') self.partner = self.env['res.partner'].create({'name': 'xxx'}) + self.owner1 = self.env['res.partner'].create({'name': 'owner1'}) self.uom_unit = self.env.ref('product.product_uom_unit') self.product1 = self.env['product.product'].create({ 'name': 'Product A', @@ -2208,6 +2209,24 @@ class TestStockValuation(TransactionCase): self.assertEqual(move5.value, -477.6) + # Receives 10 units but assign them to an owner, the valuation should not be impacted. + move6 = self.env['stock.move'].create({ + 'name': '10 units to an owner', + 'location_id': self.supplier_location.id, + 'location_dest_id': self.stock_location.id, + 'product_id': self.product1.id, + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 10.0, + 'price_unit': 99, + }) + move6._action_confirm() + move6._action_assign() + move6.move_line_ids.owner_id = self.owner1.id + move6.move_line_ids.qty_done = 10.0 + move6._action_done() + + self.assertEqual(move6.value, 0) + def test_average_perpetual_2(self): self.product1.product_tmpl_id.cost_method = 'average' diff --git a/addons/stock_account/views/stock_account_views.xml b/addons/stock_account/views/stock_account_views.xml index 20c8f9fcd3b..e169a2b7a8d 100644 --- a/addons/stock_account/views/stock_account_views.xml +++ b/addons/stock_account/views/stock_account_views.xml @@ -43,7 +43,7 @@ <field name="inherit_id" ref="stock.view_stock_return_picking_form"/> <field name="model">stock.return.picking</field> <field name="arch" type="xml"> - <field name="quantity" position="after"> + <field name="uom_id" position="after"> <field name="to_refund"/> </field> </field> diff --git a/addons/test_mail/tests/test_performance.py b/addons/test_mail/tests/test_performance.py index 06757e15dcf..1ca37543386 100644 --- a/addons/test_mail/tests/test_performance.py +++ b/addons/test_mail/tests/test_performance.py @@ -62,7 +62,7 @@ class TestMailPerformance(TestPerformance): records.write({'value': self.int(20)}) - @queryCount(admin=20, demo=31) + @queryCount(admin=33, demo=47) def test_write_mail_with_tracking(self): """ Write records inheriting from 'mail.thread' (with field tracking). """ record = self.env['test_performance.mail'].search([], limit=1) @@ -77,7 +77,7 @@ class TestMailPerformance(TestPerformance): model = self.env['test_performance.mail'] model.with_context(tracking_disable=True).create({'name': self.str('X')}) - @queryCount(admin=38, demo=54) + @queryCount(admin=63, demo=85) def test_create_mail_with_tracking(self): """ Create records inheriting from 'mail.thread' (with field tracking). """ model = self.env['test_performance.mail'] diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index 438270ecfe6..a8a32347882 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -608,7 +608,7 @@ class WebClient(http.Controller): def version_info(self): return odoo.service.common.exp_version() - @http.route('/web/tests', type='http', auth="none") + @http.route('/web/tests', type='http', auth="user") def test_suite(self, mod=None, **kwargs): return request.render('web.qunit_suite') diff --git a/addons/web/models/models.py b/addons/web/models/models.py index 09d56349048..edf167df6c9 100644 --- a/addons/web/models/models.py +++ b/addons/web/models/models.py @@ -1,7 +1,13 @@ # -*- coding: utf-8 -*- +from datetime import datetime +import babel.dates +import pytz +from odoo.tools import pycompat +from odoo.tools.misc import DEFAULT_SERVER_DATETIME_FORMAT, DEFAULT_SERVER_DATE_FORMAT from odoo import _, api, fields, models + class Base(models.AbstractModel): _inherit = 'base' @@ -19,12 +25,43 @@ class Base(models.AbstractModel): :return a dictionnary mapping group_by values to dictionnaries mapping progress bar field values to the related number of records """ + + # Workaround to match read_group's infrastructure + # TO DO in master: harmonize this function and readgroup to allow factorization + group_by_modifier = group_by.partition(':')[2] or 'month' + group_by = group_by.partition(':')[0] + display_date_formats = { + 'day': 'dd MMM yyyy', + 'week': "'W'w YYYY", + 'month': 'MMMM yyyy', + 'quarter': 'QQQ yyyy', + 'year': 'yyyy'} + fields = [progress_bar['field'], group_by] records_values = self.search_read(domain or [], fields) data = {} for record_values in records_values: group_by_value = record_values[group_by] + + # Again, imitating what _read_group_format_result and _read_group_prepare_data do + field_type = self._fields[group_by].type + if field_type in ['date', 'datetime'] and isinstance(group_by_value, pycompat.string_types): + locale = self._context.get('lang') or 'en_US' + dt_format = DEFAULT_SERVER_DATETIME_FORMAT if field_type == 'datetime' else DEFAULT_SERVER_DATE_FORMAT + group_by_value = datetime.strptime(group_by_value, dt_format) + group_by_value = pytz.timezone('UTC').localize(group_by_value) + tz_info = None + if field_type == 'datetime' and self._context.get('tz') in pytz.all_timezones: + tz_info = self._context.get('tz') + group_by_value = babel.dates.format_datetime( + group_by_value, format=display_date_formats[group_by_modifier], + tzinfo=tz_info, locale=locale) + else: + group_by_value = babel.dates.format_date( + group_by_value, format=display_date_formats[group_by_modifier], + locale=locale) + if type(group_by_value) == tuple: group_by_value = group_by_value[1] # FIXME should use technical value (0) diff --git a/addons/web/static/src/js/chrome/action_manager.js b/addons/web/static/src/js/chrome/action_manager.js index c9eed9b2e64..e72db2c3a0c 100644 --- a/addons/web/static/src/js/chrome/action_manager.js +++ b/addons/web/static/src/js/chrome/action_manager.js @@ -941,7 +941,7 @@ var ActionManager = Widget.extend({ }); }); }, - ir_actions_act_url: function (action) { + ir_actions_act_url: function (action, options) { var url = action.url; if (session.debug && url && url.length && url[0] === '/') { url = $.param.querystring(url, {debug: session.debug}); @@ -952,6 +952,7 @@ var ActionManager = Widget.extend({ return $.Deferred(); // The action is finished only when the redirection is done } else { window.open(url, '_blank'); + options.on_close(); } return $.when(); }, diff --git a/addons/web/static/src/js/core/qweb.js b/addons/web/static/src/js/core/qweb.js index 50742075e05..253e948d8ac 100644 --- a/addons/web/static/src/js/core/qweb.js +++ b/addons/web/static/src/js/core/qweb.js @@ -5,7 +5,18 @@ var translation = require('web.translation'); var _t = translation._t; -function QWeb(debug, default_dict) { +/** + * @param {boolean} debug + * @param {Object} default_dict + * @param {boolean} [enableTranslation=true] if true (this is the default), + * the rendering will translate all strings that are not marked with + * t-translation=off. This is useful for the kanban view, which uses a + * template which is already translated by the server + */ +function QWeb(debug, default_dict, enableTranslation) { + if (enableTranslation === undefined) { + enableTranslation = true; + } var qweb = new QWeb2.Engine(); qweb.default_dict = _.extend({}, default_dict || {}, { '_' : _, @@ -16,7 +27,7 @@ function QWeb(debug, default_dict) { 'csrf_token': odoo.csrf_token, }); qweb.debug = debug; - qweb.preprocess_node = preprocess_node; + qweb.preprocess_node = enableTranslation ? preprocess_node : function () {}; return qweb; } diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js index de5f80b0e92..eb56844de14 100644 --- a/addons/web/static/src/js/fields/basic_fields.js +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -576,8 +576,8 @@ var FieldMonetary = InputField.extend({ this.tagName = 'div'; this.className += ' o_input'; - // use the formatFloat function in edit - this.formatType = 'float'; + // do not display currency symbol in edit + this.formatOptions.noSymbol = true; } this.formatOptions.currency = this.currency; diff --git a/addons/web/static/src/js/fields/field_utils.js b/addons/web/static/src/js/fields/field_utils.js index bca74acc088..932ea997e6a 100644 --- a/addons/web/static/src/js/fields/field_utils.js +++ b/addons/web/static/src/js/fields/field_utils.js @@ -290,7 +290,7 @@ function formatMonetary(value, field, options) { digits: (currency && currency.digits) || options.digits, }); - if (!currency) { + if (!currency || options.noSymbol) { return formatted_value; } if (currency.position === "after") { diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js index 1c4c072726b..c4697cdafe1 100644 --- a/addons/web/static/src/js/fields/relational_fields.js +++ b/addons/web/static/src/js/fields/relational_fields.js @@ -824,12 +824,9 @@ var FieldX2Many = AbstractField.extend({ return this._super(); } if (this.renderer) { - this.renderer.updateState(this.value, {}); this.currentColInvisibleFields = this._evalColumnInvisibleFields(); - this.pager.updateState({ - size: this.value.count, - columnInvisibleFields: this.currentColInvisibleFields, - }); + this.renderer.updateState(this.value, {'columnInvisibleFields': this.currentColInvisibleFields}); + this.pager.updateState({ size: this.value.count }); return $.when(); } var arch = this.view.arch; @@ -839,7 +836,7 @@ var FieldX2Many = AbstractField.extend({ this.currentColInvisibleFields = this._evalColumnInvisibleFields(); this.renderer = new ListRenderer(this, this.value, { arch: arch, - mode: this.mode, + editable: this.mode === 'edit' && arch.attrs.editable, addCreateLine: !this.isReadonly && this.activeActions.create, addTrashIcon: !this.isReadonly && this.activeActions.delete, viewType: viewType, @@ -1380,12 +1377,12 @@ var FieldMany2ManyBinaryMultiFiles = AbstractField.extend({ throw _.str.sprintf(msg, this.field.string); } + this.uploadedFiles = {}; this.uploadingFiles = []; this.fileupload_id = _.uniqueId('oe_fileupload_temp'); $(window).on(this.fileupload_id, this._onFileLoaded.bind(this)); this.metadata = {}; - this._generatedMetadata(); }, destroy: function () { @@ -1415,10 +1412,10 @@ var FieldMany2ManyBinaryMultiFiles = AbstractField.extend({ _generatedMetadata: function () { var self = this; _.each(this.value.data, function (record) { - // attachments are tagged `allowUnlink` because only new attachments - // will be unlinked after deletion + // tagging `allowUnlink` ascertains if the attachment was user + // uploaded or was an existing or system generated attachment self.metadata[record.id] = { - allowUnlink: false, + allowUnlink: self.uploadedFiles[record.data.id] || false, url: self._getFileUrl(record.data), }; }); @@ -1430,6 +1427,7 @@ var FieldMany2ManyBinaryMultiFiles = AbstractField.extend({ _render: function () { // render the attachments ; as the attachments will changes after each // _setValue, we put the rendering here to ensure they will be updated + this._generatedMetadata(); this.$('.oe_placeholder_files, .oe_attachments') .replaceWith($(qweb.render('FieldBinaryFileUploader.files', { widget: this, @@ -1537,6 +1535,7 @@ var FieldMany2ManyBinaryMultiFiles = AbstractField.extend({ self.do_warn(_t('Uploading Error'), file.error); } else { attachment_ids.push(file.id); + self.uploadedFiles[file.id] = true; } }); 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 a6d5172bfe1..042d85d414a 100644 --- a/addons/web/static/src/js/views/basic/basic_renderer.js +++ b/addons/web/static/src/js/views/basic/basic_renderer.js @@ -251,13 +251,12 @@ var BasicRenderer = AbstractRenderer.extend({ function _apply(element) { // If the view is in edit mode and that a widget have to switch // its "readonly" state, we have to re-render it completely - if ('readonly' in modifiers - && self.mode === "edit" - && element.widget - && (element.widget.mode === 'readonly') !== modifiers.readonly) - { - self._rerenderFieldWidget(element.widget, record); - return; // Rerendering already applied the modifiers, no need to go further + if ('readonly' in modifiers && element.widget) { + var mode = modifiers.readonly ? 'readonly' : modifiersData.baseMode; + if (mode !== element.widget.mode) { + self._rerenderFieldWidget(element.widget, record, mode); + return; // Rerendering already applied the modifiers, no need to go further + } } // Toggle modifiers CSS classes if necessary @@ -429,6 +428,12 @@ var BasicRenderer = AbstractRenderer.extend({ this.allModifiersData.push(modifiersData); } } + // we register here the base mode of the node. This is a field widget + // specific settings which represents the generic mode for the widget, + // regardless of its modifiers. The interesting case is the list view: + // all widgets are supposed to be in the baseMode 'readonly', except the + // ones that are in the line that is currently being edited. + modifiersData.baseMode = (options && options.mode) || this.mode; // Evaluate if necessary if (!modifiersData.evaluatedModifiers[record.id]) { @@ -454,7 +459,7 @@ var BasicRenderer = AbstractRenderer.extend({ } modifiersData.elementsByRecord[record.id].push(newElement); - this._applyModifiers(modifiersData, record, newElement); + this._applyModifiers(modifiersData, record, newElement, options); } return modifiersData.evaluatedModifiers[record.id]; @@ -493,22 +498,20 @@ var BasicRenderer = AbstractRenderer.extend({ * @param {Object} node * @param {Object} record * @param {Object} [options] - * @param {Object} [modifiersOptions] * @returns {jQueryElement} */ - _renderFieldWidget: function (node, record, options, modifiersOptions) { + _renderFieldWidget: function (node, record, options) { var fieldName = node.attrs.name; - // Register the node-associated modifiers - var modifiers = this._registerModifiers(node, record); - + var mode = options && options.mode || this.mode; + var modifiers = this._registerModifiers(node, record, null, options); // Initialize and register the widget // Readonly status is known as the modifiers have just been registered var Widget = record.fieldsInfo[this.viewType][fieldName].Widget; - var widget = new Widget(this, fieldName, record, _.extend({ - mode: modifiers.readonly ? 'readonly' : this.mode, + var widget = new Widget(this, fieldName, record, { + mode: modifiers.readonly ? 'readonly' : mode, viewType: this.viewType, - }, options || {})); + }); // Register the widget so that it can easily be found again if (this.allFieldWidgets[record.id] === undefined) { @@ -534,15 +537,16 @@ var BasicRenderer = AbstractRenderer.extend({ if (async) { $el.replaceWith(widget.$el); } - self._registerModifiers(node, record, widget, _.extend({ + self._registerModifiers(node, record, widget, { callback: function (element, modifiers, record) { element.$el.toggleClass('o_field_empty', !!( record.data.id - && (modifiers.readonly || self.mode === 'readonly') + && (modifiers.readonly || mode === 'readonly') && !element.widget.isSet() )); }, - }, modifiersOptions || {})); + mode: mode + }); self._postProcessField(widget, node); }); @@ -610,10 +614,11 @@ var BasicRenderer = AbstractRenderer.extend({ * @private * @param {Widget} widget * @param {Object} record + * @param {string} mode either 'readonly' or 'edit' */ - _rerenderFieldWidget: function (widget, record) { + _rerenderFieldWidget: function (widget, record, mode) { // Render the new field widget - var $el = this._renderFieldWidget(widget.__node, record); + var $el = this._renderFieldWidget(widget.__node, record, {mode: mode}); widget.$el.replaceWith($el); // Destroy the old widget and position the new one at the old one's diff --git a/addons/web/static/src/js/views/graph/graph_model.js b/addons/web/static/src/js/views/graph/graph_model.js index a6d2b057d0e..0518e279637 100644 --- a/addons/web/static/src/js/views/graph/graph_model.js +++ b/addons/web/static/src/js/views/graph/graph_model.js @@ -67,7 +67,6 @@ return AbstractModel.extend({ domain: params.domain, context: params.context, }; - this.defaultGroupedBy = params.groupedBy; return this._loadGraph(); }, /** @@ -96,7 +95,7 @@ return AbstractModel.extend({ this.chart.domain = params.domain; } if ('groupBy' in params) { - this.chart.groupedBy = params.groupBy.length ? params.groupBy : this.defaultGroupedBy; + this.chart.groupedBy = params.groupBy.length ? params.groupBy : this.initialGroupBys; } if ('measure' in params) { this.chart.measure = params.measure; @@ -121,7 +120,7 @@ return AbstractModel.extend({ * @returns {Deferred} */ _loadGraph: function () { - var groupedBy = this.chart.groupedBy.length ? this.chart.groupedBy : this.initialGroupBys; + var groupedBy = this.chart.groupedBy; var fields = _.map(groupedBy, function (groupBy) { return groupBy.split(':')[0]; }); diff --git a/addons/web/static/src/js/views/kanban/kanban_column.js b/addons/web/static/src/js/views/kanban/kanban_column.js index d39a3b280e1..0e50e37194f 100644 --- a/addons/web/static/src/js/views/kanban/kanban_column.js +++ b/addons/web/static/src/js/views/kanban/kanban_column.js @@ -41,7 +41,7 @@ var KanbanColumn = Widget.extend({ this.data = data; var value = data.value; - this.id = data.res_id || value; + this.id = data.res_id; this.folded = !data.isOpen; this.has_active_field = 'active' in data.fields; this.fields = data.fields; diff --git a/addons/web/static/src/js/views/kanban/kanban_renderer.js b/addons/web/static/src/js/views/kanban/kanban_renderer.js index 029cef7cf0b..0f91fb2ae14 100644 --- a/addons/web/static/src/js/views/kanban/kanban_renderer.js +++ b/addons/web/static/src/js/views/kanban/kanban_renderer.js @@ -94,7 +94,7 @@ var KanbanRenderer = BasicRenderer.extend({ this._super.apply(this, arguments); this.widgets = []; - this.qweb = new QWeb(session.debug, {_s: session.origin}); + this.qweb = new QWeb(session.debug, {_s: session.origin}, false); var templates = findInNode(this.arch, function (n) { return n.tag === 'templates';}); transformQwebTemplate(templates, state.fields); this.qweb.add_template(utils.json_node_to_xml(templates)); @@ -235,6 +235,10 @@ var KanbanRenderer = BasicRenderer.extend({ } }); + // remove previous sorting + if(this.$el.sortable('instance') !== undefined) { + this.$el.sortable('destroy'); + } if (this.groupedByM2O) { // Enable column sorting this.$el.sortable({ @@ -249,7 +253,10 @@ var KanbanRenderer = BasicRenderer.extend({ stop: function () { var ids = []; self.$('.o_kanban_group').each(function (index, u) { - ids.push($(u).data('id')); + // Ignore 'Undefined' column + if (_.isNumber($(u).data('id'))) { + ids.push($(u).data('id')); + } }); self.trigger_up('resequence_columns', {ids: ids}); }, diff --git a/addons/web/static/src/js/views/kanban/kanban_renderer_mobile.js b/addons/web/static/src/js/views/kanban/kanban_renderer_mobile.js index e2b08fc0da9..7614d6bca95 100644 --- a/addons/web/static/src/js/views/kanban/kanban_renderer_mobile.js +++ b/addons/web/static/src/js/views/kanban/kanban_renderer_mobile.js @@ -12,6 +12,7 @@ var config = require('web.config'); var core = require('web.core'); var KanbanRenderer = require('web.KanbanRenderer'); +var _t = core._t; var qweb = core.qweb; if (!config.device.isMobile) { @@ -107,8 +108,9 @@ KanbanRenderer.include({ var updateFunc = animate ? 'animate' : 'css'; self.$('.o_kanban_mobile_tab').removeClass('o_current'); _.each(self.widgets, function (column, index) { - var $column = self.$('.o_kanban_group[data-id=' + column.id + ']'); - var $tab = self.$('.o_kanban_mobile_tab[data-id=' + column.id + ']'); + var columnID = column.id || column.db_id; + var $column = self.$('.o_kanban_group[data-id="' + columnID + '"]'); + var $tab = self.$('.o_kanban_mobile_tab[data-id="' + columnID + '"]'); if (index === moveToIndex - 1) { $column[updateFunc]({left: '-100%'}); $tab[updateFunc]({left: '0%'}); @@ -137,8 +139,19 @@ KanbanRenderer.include({ * @private */ _renderGrouped: function (fragment) { + var data = []; + _.each(this.state.data, function (group) { + if (!group.value) { + group = _.extend({}, group, {value: _t('Undefined')}); + data.unshift(group); + } + else { + data.push(group); + } + }); + var $tabs = $(qweb.render('KanbanView.MobileTabs', { - data: this.state.data, + data: data, })); $tabs.appendTo(fragment); return this._super.apply(this, arguments); 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 c1ef23b6b5e..b57d44e7de2 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 @@ -54,7 +54,7 @@ ListRenderer.include({ * @returns {Deferred} */ start: function () { - if (this.mode === 'edit') { + if (this._isEditable()) { this.$el.css({height: '100%'}); core.bus.on('click', this, this._onWindowClicked.bind(this)); } @@ -295,12 +295,7 @@ ListRenderer.include({ renderInvisible: editMode, renderWidgets: editMode, }; - if (!editMode) { - // Force 'readonly' mode for widgets in readonly rows as - // otherwise they default to the view mode which is 'edit' for - // an editable list view - options.mode = 'readonly'; - } + options.mode = editMode ? 'edit' : 'readonly'; // Switch each cell to the new mode; note: the '_renderBodyCell' // function might fill the 'this.defs' variables with multiple deferred @@ -417,7 +412,7 @@ ListRenderer.include({ * @returns {boolean} */ _isEditable: function () { - return this.mode === 'edit' && !this.state.groupedBy.length && this.arch.attrs.editable; + return !this.state.groupedBy.length && this.editable; }, /** * Move the cursor on the end of the previous line, if possible. @@ -547,21 +542,49 @@ ListRenderer.include({ var row = _.findWhere(rows, {id: movedRecordID}); var index0 = rows.indexOf(row); var index1 = ui.item.index(); - rows = rows.slice(Math.min(index0, index1), Math.max(index0, index1) + 1); - rows = _.without(rows, row); - if (index0 > index1) { - rows.unshift(row); + var lower = Math.min(index0, index1); + var upper = Math.max(index0, index1) + 1; + + var order = _.findWhere(self.state.orderedBy, {name: self.handleField}); + var asc = !order || order.asc; + var reorderAll = false; + var sequence = (asc ? -1 : 1) * Infinity; + + // determine if we need to reorder all lines + _.each(rows, function (row, index) { + if ((index < lower || index >= upper) && + ((asc && sequence >= row.data[self.handleField]) || + (!asc && sequence <= row.data[self.handleField]))) { + reorderAll = true; + } + sequence = row.data[self.handleField]; + }); + + if (reorderAll) { + rows = _.without(rows, row); + rows.splice(index1, 0, row); } else { - rows.push(row); + rows = rows.slice(lower, upper); + rows = _.without(rows, row); + if (index0 > index1) { + rows.unshift(row); + } else { + rows.push(row); + } } var sequences = _.pluck(_.pluck(rows, 'data'), self.handleField); var rowIDs = _.pluck(rows, 'id'); - this.trigger_up('resequence', { - rowIDs: rowIDs, - offset: _.min(sequences), - handleField: this.handleField, + if (!asc) { + rowIDs.reverse(); + } + this.unselectRow().then(function () { + self.trigger_up('resequence', { + rowIDs: rowIDs, + offset: _.min(sequences), + handleField: self.handleField, + }); }); }, /** diff --git a/addons/web/static/src/js/views/list/list_renderer.js b/addons/web/static/src/js/views/list/list_renderer.js index b836da253c3..6b9e4aebe56 100644 --- a/addons/web/static/src/js/views/list/list_renderer.js +++ b/addons/web/static/src/js/views/list/list_renderer.js @@ -59,6 +59,7 @@ var ListRenderer = BasicRenderer.extend({ this.hasSelectors = params.hasSelectors; this.selection = []; this.pagers = []; // instantiated pagers (only for grouped lists) + this.editable = params.editable; }, //-------------------------------------------------------------------------- @@ -260,7 +261,7 @@ var ListRenderer = BasicRenderer.extend({ // We register modifiers on the <td> element so that it gets the correct // modifiers classes (for styling) - var modifiers = this._registerModifiers(node, record, $td); + var modifiers = this._registerModifiers(node, record, $td, _.pick(options, 'mode')); // If the invisible modifiers is true, the <td> element is left empty. // Indeed, if the modifiers was to change the whole cell would be // rerendered anyway. diff --git a/addons/web/static/src/js/views/list/list_view.js b/addons/web/static/src/js/views/list/list_view.js index b2295affb98..8a989dad35e 100644 --- a/addons/web/static/src/js/views/list/list_view.js +++ b/addons/web/static/src/js/views/list/list_view.js @@ -48,7 +48,7 @@ var ListView = BasicView.extend({ this.rendererParams.arch = arch; this.rendererParams.hasSelectors = 'hasSelectors' in params ? params.hasSelectors : true; - this.rendererParams.mode = mode; + this.rendererParams.editable = params.readonly ? false : arch.attrs.editable; this.loadParams.limit = this.loadParams.limit || 80; this.loadParams.type = 'list'; diff --git a/addons/web/static/src/less/search_view.less b/addons/web/static/src/less/search_view.less index a9d6d28e9e4..0261806631a 100644 --- a/addons/web/static/src/less/search_view.less +++ b/addons/web/static/src/less/search_view.less @@ -139,6 +139,9 @@ // Filters .o_filters_menu { .o-selected-li; + + max-height: ~"calc(100vh - 125px)"; + overflow: auto; .o_filter_condition { .o-search-options-dropdown-custom-li; @@ -175,6 +178,9 @@ // Group By .o_group_by_menu { .o-selected-li; + + max-height: ~"calc(100vh - 125px)"; + overflow: auto; .divider {display: none;} // hide the divider by default (no group) .o_add_group { // add button @@ -188,6 +194,9 @@ max-height: 75vh; overflow-y: auto; .o-selected-li; + + max-height: ~"calc(100vh - 125px)"; + overflow: auto; .divider {display: none;} // hide the divider by default (no favorite) .o-searchview-custom-private, .o-searchview-custom-public { diff --git a/addons/web/static/src/xml/kanban.xml b/addons/web/static/src/xml/kanban.xml index 6cbc4d55942..7afc640ccfa 100644 --- a/addons/web/static/src/xml/kanban.xml +++ b/addons/web/static/src/xml/kanban.xml @@ -9,7 +9,7 @@ </t> <t t-name="KanbanView.Group"> - <div t-attf-class="o_kanban_group#{widget.data_records.length == 0 ? ' o_kanban_no_records' : ''}" t-att-data-id="widget.id"> + <div t-attf-class="o_kanban_group#{widget.data_records.length == 0 ? ' o_kanban_no_records' : ''}" t-att-data-id="widget.id or widget.db_id"> <div class="o_kanban_header"> <div class="o_kanban_header_title" t-att-title="widget.data_records.length + ' records'" data-delay="500"> <span class="o_column_title"><t t-esc="widget.title"/></span> @@ -107,7 +107,7 @@ <t t-name="KanbanView.MobileTabs"> <div class="o_kanban_mobile_tabs"> <t t-foreach="data" t-as="group"> - <div class="o_kanban_mobile_tab" t-att-data-id="group.res_id"> + <div class="o_kanban_mobile_tab" t-att-data-id="group.res_id or group.id"> <span class="o_column_title"><t t-esc="group.value"/></span> </div> </t> diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js index 08e255777eb..6a67cc43736 100644 --- a/addons/web/static/tests/fields/basic_fields_tests.js +++ b/addons/web/static/tests/fields/basic_fields_tests.js @@ -2457,6 +2457,63 @@ QUnit.module('basic_fields', { form.destroy(); }); + QUnit.test('monetary field with currency digits != 2', function (assert) { + assert.expect(5); + + this.data.partner.records = [{ + id: 1, + bar: false, + foo: "pouet", + int_field: 68, + qux: 99.1234, + currency_id: 1, + }]; + this.data.currency.records = [{ + id: 1, + display_name: "VEF", + symbol: "Bs.F", + position: "after", + digits: [16, 4], + }]; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<sheet>' + + '<field name="qux" widget="monetary"/>' + + '<field name="currency_id" invisible="1"/>' + + '</sheet>' + + '</form>', + res_id: 1, + session: { + currencies: _.indexBy(this.data.currency.records, 'id'), + }, + }); + + // Non-breaking space between the currency and the amount + assert.strictEqual(form.$('.o_field_widget').first().text(), '99.1234\u00a0Bs.F', + 'The value should be displayed properly.'); + + form.$buttons.find('.o_form_button_edit').click(); + assert.strictEqual(form.$('input').first().val(), '99.1234', + 'The input should be rendered without the currency symbol.'); + assert.strictEqual(form.$('input').parent().children().eq(1).text(), 'Bs.F', + 'The input should be followed by a span containing the currency symbol.'); + + form.$('input').first().val('99.111111111').trigger('input'); + assert.strictEqual(form.$('input').first().val(), '99.111111111', + 'The value should not be formated yet.'); + + form.$buttons.find('.o_form_button_save').click(); + // Non-breaking space between the currency and the amount + assert.strictEqual(form.$('.o_field_widget').first().text(), '99.1111\u00a0Bs.F', + 'The new value should be rounded properly.'); + + form.destroy(); + }); + QUnit.test('monetary field in editable list view', function (assert) { assert.expect(9); diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js index df9e7d4994b..84d095660cb 100644 --- a/addons/web/static/tests/fields/relational_fields_tests.js +++ b/addons/web/static/tests/fields/relational_fields_tests.js @@ -2274,7 +2274,7 @@ QUnit.module('relational_fields', { assert.strictEqual(form.$('td.o_data_cell:not(.o_handle_cell)').text(), "yopblipkawa", "should still have the 3 rows in the correct order"); - // Drag and drop the fourth line in second position + // Drag and drop the second line in first position testUtils.dragAndDrop( form.$('.ui-sortable-handle').eq(1), form.$('tbody tr').first(), @@ -2334,7 +2334,7 @@ QUnit.module('relational_fields', { form.$buttons.find('.o_form_button_edit').click(); - // Drag and drop the fourth line in second position + // Drag and drop the second line in first position testUtils.dragAndDrop( form.$('.ui-sortable-handle').eq(1), form.$('tbody tr').first(), @@ -2347,6 +2347,120 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('onchange for embedded one2many with handle widget using same sequence', function (assert) { + assert.expect(4); + + this.data.turtle.records[0].turtle_int = 1; + this.data.turtle.records[1].turtle_int = 1; + this.data.turtle.records[2].turtle_int = 1; + this.data.partner.records[0].turtles = [1, 2, 3]; + var turtleOnchange = 0; + this.data.turtle.onchanges = { + turtle_int: function () { + turtleOnchange++; + }, + }; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<sheet>' + + '<notebook>' + + '<page string="P page">' + + '<field name="turtles">' + + '<tree default_order="turtle_int">' + + '<field name="turtle_int" widget="handle"/>' + + '<field name="turtle_foo"/>' + + '</tree>' + + '</field>' + + '</page>' + + '</notebook>' + + '</sheet>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method === 'write') { + assert.deepEqual(args.args[1].turtles, [[1,1,{"turtle_int":2}],[4,2,false],[1,3,{"turtle_int":3}]], + "should change all lines that have changed (the first one doesn't change because it has the same sequence)"); + } + return this._super.apply(this, arguments); + }, + }); + + form.$buttons.find('.o_form_button_edit').click(); + + + assert.strictEqual(form.$('td.o_data_cell:not(.o_handle_cell)').text(), "yopblipkawa", + "should have the 3 rows in the correct order"); + + // Drag and drop the second line in first position + testUtils.dragAndDrop( + form.$('.ui-sortable-handle').eq(1), + form.$('tbody tr').first(), + {position: 'top'} + ); + + assert.strictEqual(form.$('td.o_data_cell:not(.o_handle_cell)').text(), "blipyopkawa", + "should still have the 3 rows in the correct order"); + assert.strictEqual(turtleOnchange, 3, "should update all lines"); + + form.$buttons.find('.o_form_button_save').click(); + form.destroy(); + }); + + QUnit.test('onchange (with command 5) for embedded one2many with handle widget', function (assert) { + assert.expect(2); + + this.data.partner.records[0].turtles = [1, 2, 3]; + this.data.partner.onchanges = { + turtles: function (obj) { + obj.turtles = [[5]].concat(obj.turtles); + }, + }; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<sheet>' + + '<group>' + + '<field name="turtles">' + + '<tree editable="bottom" default_order="turtle_int">' + + '<field name="turtle_int" widget="handle"/>' + + '<field name="turtle_foo"/>' + + '</tree>' + + '</field>' + + '</group>' + + '</sheet>' + + '</form>', + res_id: 1, + }); + + assert.strictEqual(form.$('td.o_data_cell:not(.o_handle_cell)').text(), "yopblipkawa", + "should have the 3 rows in the correct order"); + + form.$buttons.find('.o_form_button_edit').click(); + form.$('.o_field_one2many .o_list_view tbody tr:first td:first').click(); + form.$('.o_field_one2many .o_list_view tbody tr:first input:first').val('blurp').trigger('input'); + + // Drag and drop the third line in second position + testUtils.dragAndDrop( + form.$('.ui-sortable-handle').eq(2), + form.$('.o_field_one2many tbody tr').eq(1), + {position: 'top'} + ); + + form.$buttons.find('.o_form_button_save').click(); + + assert.strictEqual(form.$('.o_data_cell:not(.o_handle_cell)').text(), "blurpkawablip", + "should display to record in 'turtle_int' order"); + + form.destroy(); + }); + QUnit.test('embedded one2many with handle widget with minimum setValue calls', function (assert) { var done = assert.async(); assert.expect(20); @@ -4065,16 +4179,16 @@ QUnit.module('relational_fields', { assert.expect(5); var delta = 0; - var oldInit = AbstractField.prototype.init; - var oldDestroy = AbstractField.prototype.destroy; - AbstractField.prototype.init = function () { - delta++; - oldInit.apply(this, arguments); - }; - AbstractField.prototype.destroy = function () { - delta--; - oldDestroy.apply(this, arguments); - }; + testUtils.patch(AbstractField, { + init: function () { + delta++; + this._super.apply(this, arguments); + }, + destroy: function () { + delta--; + this._super.apply(this, arguments); + }, + }); var deactiveOnchange = true; @@ -4161,8 +4275,49 @@ QUnit.module('relational_fields', { "should correctly refresh the records after save"); form.destroy(); - AbstractField.prototype.init = oldInit; - AbstractField.prototype.destroy = oldDestroy; + testUtils.unpatch(AbstractField); + }); + + QUnit.test('editable one2many with sub widgets are rendered in readonly', function (assert) { + assert.expect(2); + + var editableWidgets = 0; + testUtils.patch(AbstractField, { + init: function () { + this._super.apply(this, arguments); + if (this.mode === 'edit') { + editableWidgets++; + } + }, + }); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<field name="turtles">' + + '<tree editable="bottom">' + + '<field name="turtle_foo" widget="char" attrs="{\'readonly\': [(\'turtle_int\', \'==\', 11111)]}"/>' + + '<field name="turtle_int"/>' + + '</tree>' + + '</field>' + + '</form>', + res_id: 1, + viewOptions: { + mode: 'edit', + }, + }); + + assert.strictEqual(editableWidgets, 1, + "o2m is only widget in edit mode"); + form.$('tbody td.o_field_x2many_list_row_add a').click(); + + assert.strictEqual(editableWidgets, 3, + "3 widgets currently in edit mode"); + + form.destroy(); + testUtils.unpatch(AbstractField); }); QUnit.test('one2many editable list with onchange keeps the order', function (assert) { @@ -9538,7 +9693,7 @@ QUnit.module('relational_fields', { }); QUnit.test('one2many invisible depends on parent field', function (assert) { - assert.expect(2); + assert.expect(4); this.data.partner.records[0].p = [2]; var form = createView({ @@ -9547,12 +9702,15 @@ QUnit.module('relational_fields', { data: this.data, arch:'<form string="Partners">' + '<sheet>' + + '<group>' + + '<field name="product_id"/>' + + '</group>' + '<notebook>' + '<page string="Partner page">' + '<field name="bar"/>' + '<field name="p">' + '<tree>' + - '<field name="foo"/>' + + '<field name="foo" attrs="{\'column_invisible\': [(\'parent.product_id\', \'!=\', False)]}"/>' + '<field name="bar" attrs="{\'column_invisible\': [(\'parent.bar\', \'=\', False)]}"/>' + '</tree>' + '</field>' + @@ -9565,6 +9723,13 @@ QUnit.module('relational_fields', { assert.strictEqual(form.$('th').length, 2, "should be 2 columns in the one2many"); form.$buttons.find('.o_form_button_edit').click(); + form.$('.o_field_many2one[name="product_id"] input').click(); + $('li.ui-menu-item a:contains(xpad)').trigger('mouseenter').click(); + assert.strictEqual(form.$('th').length, 1, + "should be 1 column when the product_id is set"); + form.$('.o_field_many2one[name="product_id"] input').val('').trigger('keyup'); + assert.strictEqual(form.$('th').length, 2, + "should be 2 columns in the one2many when product_id is not set"); form.$('.o_field_boolean[name="bar"] input').click(); assert.strictEqual(form.$('th').length, 1, "should be 1 column after the value change"); diff --git a/addons/web/static/tests/helpers/test_utils.js b/addons/web/static/tests/helpers/test_utils.js index 37a98de1746..e049ba968b2 100644 --- a/addons/web/static/tests/helpers/test_utils.js +++ b/addons/web/static/tests/helpers/test_utils.js @@ -434,11 +434,16 @@ function dragAndDrop($el, $to, options) { elementCenter.top += $el.outerHeight()/2; var toOffset = $to.offset(); + toOffset.top += $to.outerHeight()/2; toOffset.left += $to.outerWidth()/2; - if (position === 'center') { - toOffset.top += $to.outerHeight()/2; + if (position === 'top') { + toOffset.top -= $to.outerHeight()/2; } else if (position === 'bottom') { - toOffset.top += $to.outerHeight(); + toOffset.top += $to.outerHeight()/2; + } else if (position === 'left') { + toOffset.left -= $to.outerWidth()/2; + } else if (position === 'right') { + toOffset.left += $to.outerWidth()/2; } $el.trigger($.Event("mousedown", { @@ -551,6 +556,51 @@ function removeSrcAttribute($el, widget) { }); } +var patches = {}; +/** + * Patches a given Class with the given properties. + * + * @param {Class} Klass + * @param {Object} props + */ +function patch (Klass, props) { + var patchID = _.uniqueId('patch_'); + Klass.__patchID = patchID; + patches[patchID] = { + Klass: Klass, + otherPatchedProps: [], + ownPatchedProps: [], + }; + _.each(props, function (value, key) { + if (Klass.prototype.hasOwnProperty(key)) { + patches[patchID].ownPatchedProps.push({ + key: key, + initialValue: Klass.prototype[key], + }); + } else { + patches[patchID].otherPatchedProps.push(key); + } + }); + Klass.include(props); +} +/** + * Unpatches a given Class. + * + * @param {Class} Klass + */ +function unpatch(Klass) { + var patchID = Klass.__patchID; + var patch = patches[patchID]; + _.each(patch.ownPatchedProps, function (p) { + Klass[p.key] = p.initialValue; + }); + _.each(patch.otherPatchedProps, function (key) { + delete Klass.prototype[key]; + }); + delete patches[patchID]; + delete Klass.__patchID; +} + // Loading static files cannot be properly simulated when their real content is // really needed. This is the case for static XML files so we load them here, // before starting the qunit test suite. @@ -566,18 +616,20 @@ return $.when( QUnit.start(); }, 0); return { - intercept: intercept, - observe: observe, - createView: createView, + addMockEnvironment: addMockEnvironment, createAsyncView: createAsyncView, createModel: createModel, createParent: createParent, - addMockEnvironment: addMockEnvironment, + createView: createView, dragAndDrop: dragAndDrop, + intercept: intercept, + observe: observe, + patch: patch, + removeSrcAttribute: removeSrcAttribute, + triggerKeypressEvent: triggerKeypressEvent, triggerMouseEvent: triggerMouseEvent, triggerPositionalMouseEvent: triggerPositionalMouseEvent, - triggerKeypressEvent: triggerKeypressEvent, - removeSrcAttribute: removeSrcAttribute, + unpatch: unpatch, }; }); diff --git a/addons/web/static/tests/views/form_benchmarks.js b/addons/web/static/tests/views/form_benchmarks.js new file mode 100644 index 00000000000..d664859ba2f --- /dev/null +++ b/addons/web/static/tests/views/form_benchmarks.js @@ -0,0 +1,95 @@ +odoo.define('web.list_benchmarks', function (require) { +"use strict"; + +var FormView = require('web.FormView'); +var testUtils = require('web.test_utils'); + +var createView = testUtils.createView; + +QUnit.module('Form View', { + beforeEach: function () { + this.data = { + foo: { + fields: { + many2many: { string: "bar", type: "many2many", relation: 'bar'}, + }, + records: [ + { id: 1, many2many: []}, + ], + onchanges: {} + }, + bar: { + fields: { + char: {string: "char", type: "char"}, + many2many: { string: "pokemon", type: "many2many", relation: 'pokemon'}, + }, + records: [], + onchanges: {} + }, + pokemon: { + fields: { + name: {string: "Name", type: "char"}, + }, + records: [], + onchanges: {} + }, + }; + this.arch = null; + this.run = function (assert, done, cb) { + var data = this.data; + var arch = this.arch; + var viewOptions = this.viewOptions; + new Benchmark.Suite({}) + .add('form', function () { + var list = createView({ + View: FormView, + model: 'foo', + data: data, + arch: arch, + res_id: 1, + }); + if (cb) { + cb(list); + } + list.destroy(); + }) + .on('cycle', function(event) { + assert.ok(true, String(event.target)); + }) + .on('complete', done) + .run({ 'async': true }); + }; + } +}, function () { + QUnit.test('x2many with 250 rows, 2 fields (with many2many_tags, and modifiers), onchanges, and edition', function (assert) { + var done = assert.async(); + assert.expect(1); + + this.data.foo.onchanges.many2many = function (obj) { + obj.many2many = [5].concat(obj.many2many); + }; + for (var i = 2; i < 500; i) { + this.data.bar.records.push({ + id: i, + char: "automated data", + }); + this.data.foo.records[0].many2many.push(i); + } + this.arch = + '<form string="Partners">' + '<field name="many2many">' + '<tree editable="top" limit="250">' + '<field name="char"/>' + '<field name="many2many" widget="many2many_tags" attrs="{\'readonly\': [(\'char\', \'==\', \'toto\')]}"/>' + '</tree>' + '</field>' + '</form>'; + this.run(assert, done, function (form) { + form.$buttons.find('.o_form_button_edit').click(); + form.$('.o_data_cell:first').click(); + form.$('input:first').val("tralala").trigger('input'); + }); + }); +}); + +});
\ No newline at end of file diff --git a/addons/web/static/tests/views/graph_tests.js b/addons/web/static/tests/views/graph_tests.js index 07f7d060347..f7d22f27a64 100644 --- a/addons/web/static/tests/views/graph_tests.js +++ b/addons/web/static/tests/views/graph_tests.js @@ -90,7 +90,6 @@ QUnit.module('Views', { }); }); - QUnit.test('default type attribute', function (assert) { assert.expect(1); @@ -106,7 +105,6 @@ QUnit.module('Views', { graph.destroy(); }); - QUnit.test('switching mode', function (assert) { assert.expect(6); @@ -191,7 +189,6 @@ QUnit.module('Views', { }); }); - QUnit.test('no content helper', function (assert) { var done = assert.async(); assert.expect(4); @@ -442,6 +439,43 @@ QUnit.module('Views', { graph.destroy(); }); + + QUnit.test('initial groupby is kept when reloading', function (assert) { + var done = assert.async(); + assert.expect(4); + + var graph = createView({ + View: GraphView, + model: 'foo', + data: this.data, + arch: '<graph>' + + '<field name="product_id" type="row"/>' + + '<field name="foo" type="measure"/>' + + '</graph>', + mockRPC: function (route, args) { + if (args.method === 'read_group') { + assert.deepEqual(args.kwargs.groupby, ['product_id'], + "should group by the correct field"); + } + return this._super.apply(this, arguments); + }, + }); + + + return concurrency.delay(0).then(function () { + assert.strictEqual(graph.$('.nv-groups rect').length, 2, + "should display two groups"); + + graph.reload({groupBy: []}); + return concurrency.delay(0).then(function () { + assert.strictEqual(graph.$('.nv-groups rect').length, 2, + "should still display two groups"); + + graph.destroy(); + done(); + }); + }); + }); }); }); diff --git a/addons/web/static/tests/views/kanban_mobile_tests.js b/addons/web/static/tests/views/kanban_mobile_tests.js index d777f5bd87b..0ee35c2c9c3 100644 --- a/addons/web/static/tests/views/kanban_mobile_tests.js +++ b/addons/web/static/tests/views/kanban_mobile_tests.js @@ -26,6 +26,7 @@ QUnit.module('Views', { {id: 2, bar: true, foo: "blip", int_field: 9, qux: 13, product_id: 5, state: "def", category_ids: [6]}, {id: 3, bar: true, foo: "gnap", int_field: 17, qux: -3, product_id: 3, state: "ghi", category_ids: [7]}, {id: 4, bar: false, foo: "blip", int_field: -4, qux: 9, product_id: 5, state: "ghi", category_ids: []}, + {id: 5, bar: false, foo: "Hello \"World\"! #peace_n'_love", int_field: -9, qux: 10, state: "jkl", category_ids: []}, ] }, product: { @@ -55,7 +56,7 @@ QUnit.module('Views', { QUnit.module('KanbanView Mobile'); QUnit.test('mobile grouped rendering', function (assert) { - assert.expect(8); + assert.expect(9); var kanban = createView({ View: KanbanView, @@ -66,6 +67,7 @@ QUnit.module('Views', { '<div><field name="foo"/></div>' + '</t></templates>' + '</kanban>', + domain: [['product_id', '!=', false]], groupBy: ['product_id'], }); @@ -95,6 +97,65 @@ QUnit.module('Views', { assert.ok(kanban.$('.o_kanban_group:nth(1) > div:nth(1)').hasClass('o_kanban_quick_create'), "clicking on create should open the quick_create in the second column"); + // kanban column should match kanban mobile tabs + var column_ids = kanban.$('.o_kanban_group').map(function(){ return $(this).data('id') }).get(); + var tab_ids = kanban.$('.o_kanban_mobile_tab').map(function(){ return $(this).data('id') }).get(); + assert.deepEqual(column_ids, tab_ids, "all columns data-id should match mobile tabs data-id"); + + kanban.destroy(); + }); + QUnit.test('mobile grouped with undefined column', function (assert) { + assert.expect(3); + + var kanban = createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test o_kanban_small_column">' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + }); + + // first column should be undefined with framework unique identifier + assert.strictEqual(kanban.$('.o_kanban_group').length, 3, "should have 3 columns" ); + assert.strictEqual(kanban.$('.o_kanban_group:first-child[data-id^="partner_"]').length, 1, + "Undefined column should be first and have unique framework identifier as data-id") + + // kanban column should match kanban mobile tabs + var column_ids = kanban.$('.o_kanban_group').map(function(){ return $(this).data('id') }).get(); + var tab_ids = kanban.$('.o_kanban_mobile_tab').map(function(){ return $(this).data('id') }).get(); + assert.deepEqual(column_ids, tab_ids, "all columns data-id should match mobile tabs data-id"); + + kanban.destroy(); + }); + QUnit.test('mobile grouped on many2one rendering', function (assert) { + assert.expect(3); + + var kanban = createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test o_kanban_small_column">' + + '<templates><t t-name="kanban-box">' + + '<div><field name="foo"/></div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['foo'], + }); + + // basic rendering tests + assert.strictEqual(kanban.$('.o_kanban_group').length, 4, "should have 4 columns" ); + assert.strictEqual(kanban.$('.o_kanban_group[data-id^="partner_"]').length, 4, + "all column should have framework unique identifiers"); + + // kanban column should match kanban mobile tabs + var column_ids = kanban.$('.o_kanban_group').map(function(){ return $(this).data('id') }).get(); + var tab_ids = kanban.$('.o_kanban_mobile_tab').map(function(){ return $(this).data('id') }).get(); + assert.deepEqual(column_ids, tab_ids, "all columns data-id should match mobile tabs data-id"); + kanban.destroy(); }); }); diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js index 70e60b37bd5..9c8361e63ae 100644 --- a/addons/web/static/tests/views/kanban_tests.js +++ b/addons/web/static/tests/views/kanban_tests.js @@ -3,6 +3,7 @@ odoo.define('web.kanban_tests', function (require) { var core = require('web.core'); var KanbanColumnProgressBar = require('web.KanbanColumnProgressBar'); +var KanbanRenderer = require('web.KanbanRenderer'); var KanbanView = require('web.KanbanView'); var testUtils = require('web.test_utils'); var Widget = require('web.Widget'); @@ -1015,7 +1016,19 @@ QUnit.module('Views', { }); QUnit.test('delete a column in grouped on m2o', function (assert) { - assert.expect(26); + assert.expect(32); + + testUtils.patch(KanbanRenderer, { + _renderGrouped: function () { + this._super.apply(this, arguments); + // set delay and revert animation time to 0 so dummy drag and drop works + if (this.$el.sortable('instance')) { + this.$el.sortable('option', {delay: 0, revert: 0}); + } + }, + }); + + var resequencedIDs; var kanban = createView({ View: KanbanView, @@ -1029,6 +1042,12 @@ QUnit.module('Views', { '</kanban>', groupBy: ['product_id'], mockRPC: function (route, args) { + if (route === '/web/dataset/resequence') { + resequencedIDs = args.ids; + assert.strictEqual(_.reject(args.ids, _.isNumber).length, 0, + "column resequenced should be existing records with IDs"); + return $.when(true); + } if (args.method) { assert.step(args.method); } @@ -1071,7 +1090,7 @@ QUnit.module('Views', { assert.strictEqual(kanban.$('.o_kanban_group:last').data('id'), 3, 'last column should now be [3, "hello"]'); assert.strictEqual(kanban.$('.o_kanban_group').length, 2, "should still have two columns"); - assert.ok(!kanban.$('.o_kanban_group:first').data('id'), + assert.ok(!_.isNumber(kanban.$('.o_kanban_group:first').data('id')), 'first column should have no id (Undefined column)'); // check available actions on 'Undefined' column assert.ok(kanban.$('.o_kanban_group:first .o_kanban_toggle_fold').length, @@ -1087,7 +1106,32 @@ QUnit.module('Views', { assert.verifySteps(['read_group', 'unlink', 'read_group']); assert.strictEqual(kanban.renderer.widgets.length, 2, "the old widgets should have been correctly deleted"); + + // test column drag and drop having an 'Undefined' column + testUtils.dragAndDrop( + kanban.$('.o_kanban_header_title:first'), + kanban.$('.o_kanban_header_title:last'), {position: 'right'} + ); + assert.strictEqual(resequencedIDs, undefined, + "resequencing require at least 2 not Undefined columns"); + kanban.$('.o_column_quick_create input').val('once third column'); + kanban.$('.o_column_quick_create button.o_kanban_add').click(); + var newColumnID = kanban.$('.o_kanban_group:last').data('id'); + testUtils.dragAndDrop( + kanban.$('.o_kanban_header_title:first'), + kanban.$('.o_kanban_header_title:last'), {position: 'right'} + ); + assert.deepEqual([3, newColumnID], resequencedIDs, + "moving the Undefined column should not affect order of other columns") + testUtils.dragAndDrop( + kanban.$('.o_kanban_header_title:first'), + kanban.$('.o_kanban_header_title:nth(1)'), {position: 'right'} + ); + assert.deepEqual([newColumnID, 3], resequencedIDs, + "moved column should be resequenced accordingly") + kanban.destroy(); + testUtils.unpatch(KanbanRenderer); }); QUnit.test('create a column, delete it and create another one', function (assert) { diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js index 9180015685e..2993e393a4f 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -415,11 +415,12 @@ QUnit.module('Views', { QUnit.test('editable list: add a line and discard', function (assert) { assert.expect(11); - var oldDestroy = basicFields.FieldChar.prototype.destroy; - basicFields.FieldChar.prototype.destroy = function () { - assert.step('destroy'); - oldDestroy.apply(this, arguments); - }; + testUtils.patch(basicFields.FieldChar, { + destroy: function () { + assert.step('destroy'); + this._super.apply(this, arguments); + }, + }); var list = createView({ View: ListView, @@ -456,7 +457,7 @@ QUnit.module('Views', { assert.verifySteps(['destroy'], "should have destroyed the widget of the removed line"); - basicFields.FieldChar.prototype.destroy = oldDestroy; + testUtils.unpatch(basicFields.FieldChar); list.destroy(); }); @@ -473,13 +474,13 @@ QUnit.module('Views', { var n = 0; testUtils.intercept(list, "field_changed", function () { - n = 1; + n += 1; }); $td.click(); $td.find('input').val('abc').trigger('input'); - assert.strictEqual(n, 1, "field_changed should not have been triggered"); - list.$('td:not(.o_list_record_selector)').eq(2).click(); assert.strictEqual(n, 1, "field_changed should have been triggered"); + list.$('td:not(.o_list_record_selector)').eq(2).click(); + assert.strictEqual(n, 1, "field_changed should not have been triggered"); list.destroy(); }); diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml index c3d4e4eceae..a02bbdbcf6a 100644 --- a/addons/web/views/webclient_templates.xml +++ b/addons/web/views/webclient_templates.xml @@ -587,6 +587,7 @@ <script type="text/javascript" src="/web/static/tests/views/list_benchmarks.js"></script> <script type="text/javascript" src="/web/static/tests/views/kanban_benchmarks.js"></script> + <script type="text/javascript" src="/web/static/tests/views/form_benchmarks.js"></script> </t> <div id="qunit"/> diff --git a/addons/web_editor/static/src/js/backend/fields.js b/addons/web_editor/static/src/js/backend/fields.js index 95afaa90073..cbdb6cbb88b 100644 --- a/addons/web_editor/static/src/js/backend/fields.js +++ b/addons/web_editor/static/src/js/backend/fields.js @@ -221,7 +221,8 @@ var FieldTextHtml = AbstractField.extend({ start: function () { var self = this; - this.loaded = false; + this.editorLoadedDeferred = $.Deferred(); + this.contentLoadedDeferred = $.Deferred(); this.callback = _.uniqueId('FieldTextHtml_'); window.odoo[this.callback+"_editor"] = function (EditorBar) { setTimeout(function () { @@ -312,8 +313,6 @@ var FieldTextHtml = AbstractField.extend({ old_initialize_content: function () { this.$el.closest('.modal-body').css('max-height', 'none'); this.$iframe = this.$el.find('iframe'); - // deactivate any button to avoid saving a not ready iframe - $('.o_cp_buttons, .o_statusbar_buttons').find('button').addClass('o_disabled').attr('disabled', true); this.document = null; this.$body = $(); this.$content = $(); @@ -328,9 +327,7 @@ var FieldTextHtml = AbstractField.extend({ this.$content = this.$body.find("#editable_area"); this.render(); this.add_button(); - this.loaded = true; - // reactivate all the buttons when the field's content (the iframe) is loaded - $('.o_cp_buttons, .o_statusbar_buttons').find('button').removeClass('o_disabled').attr('disabled', false); + this.contentLoadedDeferred.resolve(); setTimeout(self.resize, 0); }, on_editor_loaded: function (EditorBar) { @@ -339,6 +336,7 @@ var FieldTextHtml = AbstractField.extend({ if (this.value && window.odoo[self.callback+"_updown"] && !(this.$content.html()||"").length) { this.render(); } + this.editorLoadedDeferred.resolve(); setTimeout(function () { setTimeout(self.resize,0); }, 0); @@ -395,20 +393,25 @@ var FieldTextHtml = AbstractField.extend({ //-------------------------------------------------------------------------- /** + * Set the value when the widget is fully loaded (content + editor). + * * @override */ commitChanges: function () { - if (!this.loaded || this.mode === 'readonly') { + var self = this; + var result = this._super.bind(this, arguments); + if (this.mode === 'readonly') { return; } - // switch to WYSIWYG mode if currently in code mode to get all changes - if (config.debug && this.mode === 'edit' && this.editor.rte) { - var layoutInfo = this.editor.rte.editable().data('layoutInfo'); - $.summernote.pluginEvents.codeview(undefined, undefined, layoutInfo, false); - } - this.editor.snippetsMenu && this.editor.snippetsMenu.cleanForSave(); - this._setValue(this.$content.html()); - return this._super.apply(this, arguments); + return $.when(this.contentLoadedDeferred, this.editorLoadedDeferred, result).then(function () { + // switch to WYSIWYG mode if currently in code mode to get all changes + if (config.debug && self.editor.rte) { + var layoutInfo = self.editor.rte.editable().data('layoutInfo'); + $.summernote.pluginEvents.codeview(undefined, undefined, layoutInfo, false); + } + self.editor.snippetsMenu && self.editor.snippetsMenu.cleanForSave(); + self._setValue(self.$content.html()); + }); }, }); diff --git a/addons/web_editor/static/src/js/editor/summernote.js b/addons/web_editor/static/src/js/editor/summernote.js index 33bf1f38046..d6f9805599e 100644 --- a/addons/web_editor/static/src/js/editor/summernote.js +++ b/addons/web_editor/static/src/js/editor/summernote.js @@ -13,11 +13,64 @@ var range = $.summernote.core.range; var list = $.summernote.core.list; var key = $.summernote.core.key; var eventHandler = $.summernote.eventHandler; -// var editor = eventHandler.modules.editor; +var editor = eventHandler.modules.editor; var renderer = $.summernote.renderer; var options = $.summernote.options; +// Browser-unify execCommand +var oldJustify = {}; +_.each(['Left', 'Right', 'Full', 'Center'], function (align) { + oldJustify[align] = editor['justify' + align]; + editor['justify' + align] = function ($editable, value) { + // Before calling the standard function, check all elements which have + // an 'align' attribute and mark them with their value + var $align = $editable.find('[align]'); + _.each($align, function (el) { + var $el = $(el); + $el.data('__align', $el.attr('align')); + }); + + // Call the standard function + oldJustify[align].apply(this, arguments); + + // Then: + + // Remove the text-align of elements which lost the 'align' attribute + var $newAlign = $editable.find('[align]'); + $align.not($newAlign).css('text-align', ''); + + // Transform the 'align' attribute into the 'text-align' css + // property for elements which received the 'align' attribute or whose + // 'align' attribute changed + _.each($newAlign, function (el) { + var $el = $(el); + + var oldAlignValue = $align.data('__align'); + var alignValue = $el.attr('align'); + if (oldAlignValue === alignValue) { + // If the element already had an 'align' attribute and that it + // did not changed, do nothing (compatibility) + return; + } + + $el.removeAttr('align'); + $el.css('text-align', alignValue); + + // Note the first step (removing the text-align of elemnts which + // lost the 'align' attribute) is kinda the same as this one, but + // this one handles the elements which have been edited with chrome + // or with this new system + $el.find('*').css('text-align', ''); + }); + + // Unmark the elements + $align.removeData('__align'); + }; +}); + + // Add methods to summernote + dom.hasContentAfter = function (node) { var next; if (dom.isEditable(node)) return; @@ -2289,26 +2342,6 @@ eventHandler.modules.popover.update = function ($popover, oStyle, isAirMode) { } }; -// override summernote clipboard functionality -eventHandler.modules.clipboard.attach = function (layoutInfo) { - var $editable = layoutInfo.editable(); - $editable.on('paste', function (e) { - var clipboardData = ((e.originalEvent || e).clipboardData || window.clipboardData); - // Change nothing if pasting html (copy from text editor / web / ...) or - // if clipboardData is not available (IE / ...) - if (clipboardData && clipboardData.types && clipboardData.types.length === 1 && clipboardData.types[0] === "text/plain") { - e.preventDefault(); - $editable.data('NoteHistory').recordUndo($editable); // FIXME - var pastedText = clipboardData.getData("text/plain"); - // Try removing linebreaks which are not really linebreaks (in a PDF, - // when a sentence goes over the next line, copying it considers it - // a linebreak for example). - var formattedText = pastedText.replace(/([\w-])\r?\n([\w-])/g, "$1 $2").trim(); - document.execCommand("insertText", false, formattedText); - } - }); -}; - var fn_attach = eventHandler.attach; eventHandler.attach = function (oLayoutInfo, options) { var $editable = oLayoutInfo.editor().hasClass('note-editable') ? oLayoutInfo.editor() : oLayoutInfo.editor().find('.note-editable'); diff --git a/addons/web_editor/static/tests/web_editor_tests.js b/addons/web_editor/static/tests/web_editor_tests.js index 41ada113cba..3f46edde64c 100644 --- a/addons/web_editor/static/tests/web_editor_tests.js +++ b/addons/web_editor/static/tests/web_editor_tests.js @@ -4,6 +4,7 @@ odoo.define('web_editor.web_editor_tests', function (require) { var FormView = require('web.FormView'); var testUtils = require('web.test_utils'); var core = require('web.core'); +var web_editor = require('web_editor.editor'); var _t = core._t; @@ -191,7 +192,7 @@ QUnit.test('field htmlsimple does not crash when commitChanges is called in mode QUnit.test('html_frame does not crash when saving in readonly', function (assert) { // The 'Save' action may be triggered even in readonly (e.g. when clicking // on a button in the form view) - assert.expect(0); + assert.expect(2); var form = testUtils.createView({ View: FormView, @@ -203,10 +204,13 @@ QUnit.test('html_frame does not crash when saving in readonly', function (assert '</sheet>' + '</form>', res_id: 1, - mockRPC: function (route) { + mockRPC: function (route, args) { + if (args.method) { + assert.step(args.method); + } if (_.str.startsWith(route, '/test')) { // manually call the callback to simulate that the iframe has - // been correctly loaded + // been loaded (note: just the content, not the editor) window.odoo[$.deparam(route).callback + '_content'].call(); return $.when(); } @@ -215,7 +219,109 @@ QUnit.test('html_frame does not crash when saving in readonly', function (assert }); form.saveRecord(); // before the fix done in this commit, it crashed here + + assert.verifySteps(['read']); + + form.destroy(); +}); + +QUnit.test('html_frame does not crash when saving in edit mode (editor not loaded)', function (assert) { + // The 'Save' action may be triggered when saving in edit mode very fast + // so that the editor may be not loaded, even though the content is! + assert.expect(2); + + var form = testUtils.createView({ + View: FormView, + model: 'mass.mailing', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="display_name"/>' + + '<field name="body" widget="html_frame" options="{\'editor_url\': \'/test\'}"/>' + + '</sheet>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method) { + assert.step(args.method); + } + if (_.str.startsWith(route, '/test')) { + // 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'](); + return $.when(); + } + return this._super.apply(this, arguments); + }, + }); + + form.$buttons.find('.o_form_button_edit').click(); + form.$('input').val('trululu').trigger('input'); + form.$buttons.find('.o_form_button_save').click(); // crash without editor fully loaded + + assert.verifySteps(['read']); + form.destroy(); }); +QUnit.test('html_frame saving in edit mode (editor and content fully loaded)', function (assert) { + var done = assert.async(); + assert.expect(4); + + var editorBar = new web_editor.Class(); + var loadDeferred = $.Deferred(); + var writeDeferred = $.Deferred(); + + var form = testUtils.createView({ + View: FormView, + model: 'mass.mailing', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="display_name"/>' + + '<field name="body" widget="html_frame" options="{\'editor_url\': \'/test\'}"/>' + + '</sheet>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method) { + assert.step(args.method); + if (args.method === 'write') { + writeDeferred.resolve(); + } + } + if (_.str.startsWith(route, '/test')) { + // manually call the callback to simulate that the iframe has + // been fully loaded (content + editor) + var callback = $.deparam(route).callback; + return loadDeferred.then(function () { + var contentCallback = window.odoo[callback + '_content']; + var editorCallback = window.odoo[callback + '_editor']; + if (editorCallback && contentCallback) { + contentCallback(); + editorCallback(editorBar); + } + }); + } + return this._super.apply(this, arguments); + }, + }); + + form.$buttons.find('.o_form_button_edit').click(); + form.$('input').val('trululu').trigger('input'); + form.$buttons.find('.o_form_button_save').click(); + + loadDeferred.resolve(); // simulate late loading of html frame + + assert.strictEqual(form.$('.o_field_char').val(), 'trululu', + "should have saved the char field text"); + + writeDeferred.then( function () { // html_frame is async with write + assert.verifySteps(['read', 'write']); + form.destroy(); + done(); + }); + +}); + }); diff --git a/addons/web_tour/static/src/js/tour_manager.js b/addons/web_tour/static/src/js/tour_manager.js index 65553b46bf6..4b7dde7696b 100644 --- a/addons/web_tour/static/src/js/tour_manager.js +++ b/addons/web_tour/static/src/js/tour_manager.js @@ -170,14 +170,21 @@ return core.Class.extend(mixins.EventDispatcherMixin, ServicesMixin, { console.log('Tour Manager is ready. running_tour=' + this.running_tour); }, /** - * Registers a tour described by the following arguments (in order) - * @param [String] tour's name - * @param [Object] dict of options (optional), available options are: - * test [Boolean] true if the tour is dedicated to tests (it won't be enabled by default) - * skip_enabled [Boolean] true to add a link to consume the whole tour in its tips - * url [String] the url to load when manually running the tour - * rainbowMan [Bool] use to display the rainbow effect at the end of tour - * @param [Array] dict of steps, each step being a dict containing a tip description + * Registers a tour described by the following arguments *in order* + * + * @param [string] name - tour's name + * @param [Object] - options (optional), available options are: + * @param [boolean] - true if this is only for tests + * @param [boolean] + * true to add a link in its tips to consume the whole tour + * @param [string] + * the url to load when manually running the tour + * @param [boolean] + * whether or not the rainbowman must be shown at the end of the tour + * @param [Deferred] + * indicates when the tour can be started + * @param [Object] steps - steps' descriptions, each step being an object + * containing a tip description */ register: function() { var args = Array.prototype.slice.call(arguments); @@ -193,7 +200,7 @@ return core.Class.extend(mixins.EventDispatcherMixin, ServicesMixin, { name: name, steps: steps, url: options.url, - rainbowMan: options.rainbowMan || true, + rainbowMan: options.rainbowMan === undefined ? true : !!options.rainbowMan, test: options.test, wait_for: options.wait_for || $.when(), }; @@ -402,13 +409,14 @@ return core.Class.extend(mixins.EventDispatcherMixin, ServicesMixin, { _consume_tour: function (tour_name, error) { delete this.active_tooltips[tour_name]; //display rainbow at the end of any tour - if (this.rainbowMan && this.tours[tour_name].current_step === this.tours[tour_name].steps.length){ + if (this.tours[tour_name].rainbowMan && this.running_tour !== tour_name + && this.tours[tour_name].current_step === this.tours[tour_name].steps.length) { var $rainbow_message = $('<strong>' + '<b>Good job!</b>' + ' You went through all steps of this tour.' + '</strong>'); new RainbowMan({message: $rainbow_message, click_close: false}).appendTo(this.$body); - }; + } this.tours[tour_name].current_step = 0; local_storage.removeItem(get_step_key(tour_name)); if (this.running_tour === tour_name) { diff --git a/addons/website/models/ir_http.py b/addons/website/models/ir_http.py index 696f69e8ae3..65c514487a2 100644 --- a/addons/website/models/ir_http.py +++ b/addons/website/models/ir_http.py @@ -83,7 +83,7 @@ class Http(models.AbstractModel): super(Http, cls)._add_dispatch_parameters(func) if request.routing_iteration == 1: - request.website = request.website.with_context(context) + request.website = request.website.with_context(request.context) @classmethod def _get_languages(cls): diff --git a/addons/website_event_sale/views/event_templates.xml b/addons/website_event_sale/views/event_templates.xml index e336df0e7f2..3af11508112 100644 --- a/addons/website_event_sale/views/event_templates.xml +++ b/addons/website_event_sale/views/event_templates.xml @@ -79,7 +79,7 @@ </t> </xpath> <xpath expr="//form[@id='registration_form']" position="attributes"> - <attribute name="t-if">event.event_ticket_ids</attribute> + <attribute name="t-if">event.event_ticket_ids and not all([ticket.is_expired for ticket in event.event_ticket_ids])</attribute> </xpath> </template> diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py index fde2aecc25c..f978430f938 100644 --- a/addons/website_sale/controllers/main.py +++ b/addons/website_sale/controllers/main.py @@ -833,7 +833,7 @@ class WebsiteSale(http.Controller): return request.redirect('/shop/?error=invalid_token_id') # We retrieve the token the user want to use to pay - token = request.env['payment.token'].browse(pm_id) + token = request.env['payment.token'].sudo().browse(pm_id) if not token: return request.redirect('/shop/?error=token_not_found') diff --git a/addons/website_sale/data/data.xml b/addons/website_sale/data/data.xml index 910a5f95cfd..81639c17cd0 100644 --- a/addons/website_sale/data/data.xml +++ b/addons/website_sale/data/data.xml @@ -31,6 +31,7 @@ <field name="active" eval="True"/> <field name="dashboard_graph_model">sale.report</field> <field name="dashboard_graph_period">week</field> + <field name="company_id" eval="False"/> </record> <record model="website" id="website.default_website"> diff --git a/addons/website_sale/static/src/js/website_sale.js b/addons/website_sale/static/src/js/website_sale.js index 29f3652a185..88bbb95648c 100644 --- a/addons/website_sale/static/src/js/website_sale.js +++ b/addons/website_sale/static/src/js/website_sale.js @@ -168,7 +168,7 @@ odoo.define('website_sale.website_sale', function (require) { } else { $q.parents('li:first').addClass("hidden"); - $('a[href^="/shop/checkout"]').addClass("hidden"); + $('a[href*="/shop/checkout"]').addClass("hidden"); } $q.html(data.cart_quantity).hide().fadeIn(600); diff --git a/addons/website_sale/views/res_config_settings_views.xml b/addons/website_sale/views/res_config_settings_views.xml index 9c8e10ad20f..de574739269 100644 --- a/addons/website_sale/views/res_config_settings_views.xml +++ b/addons/website_sale/views/res_config_settings_views.xml @@ -344,7 +344,7 @@ </div> </div> </div> - <div class="col-xs-12 col-md-6 o_setting_box" id="abandoned_carts_setting" title="Send a recovery email to authentified shoppers"> + <div class="col-xs-12 col-md-6 o_setting_box" id="abandoned_carts_setting" title="Send a recovery email to authenticated shoppers"> <div class="o_setting_left_pane"/> <div class="o_setting_right_pane"> <label string="Email for Abandoned carts"/> diff --git a/addons/website_sale_management/__manifest__.py b/addons/website_sale_management/__manifest__.py index 7682f8293a6..a083ca679f8 100644 --- a/addons/website_sale_management/__manifest__.py +++ b/addons/website_sale_management/__manifest__.py @@ -11,7 +11,7 @@ Display orders to invoice in website dashboard. 'website_sale', ], 'installable': True, - 'autoinstall': True, + 'auto_install': True, 'data': [ ], 'demo': [ diff --git a/addons/website_slides/controllers/main.py b/addons/website_slides/controllers/main.py index f7062cc031e..ba0b1305946 100644 --- a/addons/website_slides/controllers/main.py +++ b/addons/website_slides/controllers/main.py @@ -78,7 +78,7 @@ class WebsiteSlides(http.Controller): @http.route([ '''/slides/<model("slide.channel"):channel>''', - '''/slides/<model("slide.channel):channel>/page/<int:page>''', + '''/slides/<model("slide.channel"):channel>/page/<int:page>''', '''/slides/<model("slide.channel"):channel>/<string:slide_type>''', '''/slides/<model("slide.channel"):channel>/<string:slide_type>/page/<int:page>''', diff --git a/addons/website_slides/models/slides.py b/addons/website_slides/models/slides.py index c598af5104d..cb2d1836de2 100644 --- a/addons/website_slides/models/slides.py +++ b/addons/website_slides/models/slides.py @@ -281,7 +281,7 @@ class Slide(models.Model): category_id = fields.Many2one('slide.category', string="Category", domain="[('channel_id', '=', channel_id)]") tag_ids = fields.Many2many('slide.tag', 'rel_slide_tag', 'slide_id', 'tag_id', string='Tags') download_security = fields.Selection( - [('none', 'No One'), ('user', 'Authentified Users Only'), ('public', 'Everyone')], + [('none', 'No One'), ('user', 'Authenticated Users Only'), ('public', 'Everyone')], string='Download Security', required=True, default='user') image = fields.Binary('Image', attachment=True) diff --git a/doc/_static/banners/localization.jpg b/doc/_static/banners/localization.jpg Binary files differnew file mode 100644 index 00000000000..765871137f4 --- /dev/null +++ b/doc/_static/banners/localization.jpg diff --git a/doc/cla/corporate/teclib.md b/doc/cla/corporate/teclib.md index ff9db6dd21c..275a2b41300 100644 --- a/doc/cla/corporate/teclib.md +++ b/doc/cla/corporate/teclib.md @@ -12,3 +12,4 @@ List of contributors: Laurent Destailleur ldestailleur@teclib.com https://github.com/eldy David Halgand dhalgand@teclib.com https://github.com/halgandd +Guillaume Masson gmasson@teclib.com https://github.com/metaminux diff --git a/doc/webservices.rst b/doc/webservices.rst index 3dad86448ba..41e73c7604e 100644 --- a/doc/webservices.rst +++ b/doc/webservices.rst @@ -11,3 +11,4 @@ Web Services webservices/odoo webservices/iap webservices/upgrade + webservices/localization
\ No newline at end of file diff --git a/doc/webservices/iap.rst b/doc/webservices/iap.rst index 3cc82b15597..186daf772b6 100644 --- a/doc/webservices/iap.rst +++ b/doc/webservices/iap.rst @@ -41,9 +41,9 @@ In-App Purchases ================ -IAP allow providers of ongoing services through Odoo apps to be compensated -for ongoing service use rather than — and possibly instead of — a sole initial -purchase. +In-App Purchase (IAP) allow providers of ongoing services through Odoo apps to +be compensated for ongoing service use rather than — and possibly instead of +— a sole initial purchase. In that context, Odoo acts mostly as a *broker* between a client and an Odoo App Developer: @@ -75,6 +75,27 @@ Overview service directly, or you can delegate the actual service acting as a bridge/translator between an Odoo system and the actual service + +.. figure:: images/credits.jpg + :align: center + + The Credits + + Every service provided through the In-App platform can be used by the + clients with tokens or *credits*. The monetary value of a credit depends + on the service and is decided by the provider. This could be: + + * for an sms service: 1 credit = 1 sms, + * for an add service: 1 credit = 1 add, + * for a postage service: 1 credit = 1 post stamp. + + A credit can also simply be associated with a fixed amount of money + to palliate the variations of price (e.g. the prices of sms and stamps + may vary following the countries). + + The value of the credits is fixed with the help of prepaid credit packs + that the clients can buy on https://iap.odoo.com (see :ref:`Packages <iap-packages>`). + .. note:: in the following explanations we will ignore the External Service, they're just a detail of the service you provide @@ -134,10 +155,16 @@ Register the service on Odoo The first step is to register your service on the IAP endpoint (production and/or test) before you can actually query user accounts. To create a service, go to your *Portal Account* on the IAP endpoint (https://iap.odoo.com for -production, http://iap-sandbox.odoo.com for testing, the endpoints are +production, https://iap-sandbox.odoo.com for testing, the endpoints are *independent* and *not synchronized*). -Log in then go to :menuselection:`My Account --> Your InApp Services`, click +.. note:: + + On production, there is a manual validation step before the service + can be used to manage real transactions. This step is automatically passed when + on sandbox to ease the tests. + +Log in then go to :menuselection:`My Account --> Your In-App Services`, click Create and provide the name of your service. @@ -146,7 +173,8 @@ The now created service has *two* important fields: * :samp:`name` - :class:`ServiceName`: this will identify your service in the client's :ref:`app <iap-odoo-app>` communicates directly with IAP. * :samp:`key` - :class:`ServiceKey`: the developer key that identifies you in - IAP (see :ref:`your service <iap-service>`) and allows to draw credits from the client's account. + IAP (see :ref:`your service <iap-service>`) and allows to draw credits from + the client's account. .. warning:: The :class:`ServiceName` is unique and should usually match the name of your @@ -169,6 +197,27 @@ The now created service has *two* important fields: You can then create *credit packs* which clients can purchase in order to use your service. +.. _iap-packages: + +Packages +-------- + +The credit packages are essentially a product with 4 characteristics. + +* Name: the name of the package, +* Description: details on the package that will appear on the shop page as + well as the invoice, +* Credits: the amount of credits the client is entitled to when buying the package, +* Price: the price in *EUROS* for the time being (USD support is planned). + +.. image:: images/package.png + :align: center + +.. note:: + + Depending on the strategy, the price per credit can vary from one + package to another. + .. _iap-odoo-app: Odoo App diff --git a/doc/webservices/images/credits.jpg b/doc/webservices/images/credits.jpg Binary files differnew file mode 100644 index 00000000000..5eb29ea29fb --- /dev/null +++ b/doc/webservices/images/credits.jpg diff --git a/doc/webservices/images/credits2.jpg b/doc/webservices/images/credits2.jpg Binary files differnew file mode 100644 index 00000000000..aa0f0afece6 --- /dev/null +++ b/doc/webservices/images/credits2.jpg diff --git a/doc/webservices/images/package.png b/doc/webservices/images/package.png Binary files differnew file mode 100644 index 00000000000..a1627f9aecd --- /dev/null +++ b/doc/webservices/images/package.png diff --git a/doc/webservices/localization.rst b/doc/webservices/localization.rst new file mode 100644 index 00000000000..17559b2a01a --- /dev/null +++ b/doc/webservices/localization.rst @@ -0,0 +1,421 @@ +:banner: banners/localization.jpg + +========================== +Making my Own Localization +========================== + +.. warning:: + + This tutorial requires knowledges about how to build a module in Odoo. + +Building a localization module +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When installing the ``accounting`` module, the localization module corresponding to the country code of the company is installed automatically. +In case of no country code set or no localization module found, the ``l10n_generic_coa`` (US) localization module is installed by default. + +For example, ``l10n_be`` will be installed if the company has ``Belgium`` as country. + +This behavior is allowed by the presence of a *.yml* file containing the following code: + +.. code-block:: xml + + - + !python {model: account.chart.template, id: pl_chart_template}: | + self[0].try_loading_for_current_company() + +Usually located in the ``data`` folder, it must be loaded at the very last in the ``__manifest__.py`` file. + +.. danger:: + + If the *.yml* file is missing, the right chart of accounts won't be loaded on time! + + +Configuring my own Chart of Accounts? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +First of all, before I proceed, we need to talk about the templates. A template is a record that allows replica of itself. +This mechanism is needed when working in multi-companies. For example, the creation of a new account is done using the ``account.account.template`` model. +However, each company using this chart of accounts will be linked to a replica having ``account.account`` as model. +So, the templates are never used directly by the company. + +Then, when a chart of accounts needs to be installed, all templates dependent of this one will create a replica and link this newly generated record to the company's user. +It means all such templates must be linked to the chart of accounts in some way. To do so, each one must reference the desired chart of accounts using the ``chart_template_id`` field. +For this reason, we need to define an instance of the ``account.chart.template`` model before creating its templates. + +.. code-block:: xml + + <record id="..." model="account.chart.template"> + <!-- [Required] Specify the name to display for this CoA. --> + <field name="name">...</field> + + <!-- [Required] Specify the currency. E.g. "base.USD". --> + <field name="currency_id" ref="..."/> + + <!-- [Required] Specify a prefix of the bank accounts. --> + <field name="bank_account_code_prefix">...</field> + + <!-- [Required] Specify a prefix of the cash accounts. --> + <field name="cash_account_code_prefix">...</field> + + <!-- [Optional] Define a parent chart of accounts that will be installed just before this one. --> + <field name="parent_id" ref="..."/> + + <!-- [Optional] Define the number of digits of account codes. By default, the value is 6. --> + <field name="code_digits">...</field> + + <!-- [Optional] Boolean to show or not this CoA on the list. By default, the CoA is visible. + This field is mostly used when this CoA has some children (see parent_id field). --> + <field name="visible" eval="..."/> + + <!-- [Optional] Boolean to enable the Anglo-Saxon accounting. By default, this field is False. --> + <field name="use_anglo_saxon" eval="..."/> + + <!-- [Optional] Boolean to enable the complete set of taxes. By default, this field is True. + This boolean helps you to choose if you want to propose to the user to encode the sale and purchase rates or choose from list of taxes. + This last choice assumes that the set of tax defined on this template is complete. --> + <field name="complete_tax_set" eval="..."/> + + <!-- [Optional] Specify the spoken languages. + /!\ This option is only available if your module depends of l10n_multilang. + You must provide the language codes separated by ';', e.g. eval="'en_US;ar_EG;ar_SY'". --> + <field name="spoken_languages" eval="..."/> + </record> + +For example, let's take a look to the Belgium chart of accounts. + +.. code-block:: xml + + <record id="l10nbe_chart_template" model="account.chart.template"> + <field name="name">Belgian PCMN</field> + <field name="currency_id" ref="base.EUR"/> + <field name="bank_account_code_prefix">550</field> + <field name="cash_account_code_prefix">570</field> + <field name="spoken_languages" eval="'nl_BE'"/> + </record> + +Now that the chart of accounts is created, we can focus on the creation of the templates. +As said previously, each record must reference this record through the ``chart_template_id`` field. +If not, the template will be ignored. The following sections show in details how to create these templates. + +Adding a new account to my Chart of Accounts +############################################ + +It's time to create our accounts. It consists to creating records of ``account.account.template`` type. +Each ``account.account.template`` is able to create an ``account.account`` for each company. + +.. code-block:: xml + + <record id="..." model="account.account.template"> + <!-- [Required] Specify the name to display for this account. --> + <field name="name">...</field> + + <!-- [Required] Specify a code. --> + <field name="code">...</field> + + <!-- [Required] Specify a type. --> + <field name="user_type_id">...</field> + + <!-- [Required] Set the CoA owning this account. --> + <field name="chart_template_id" ref="..."/> + + <!-- [Optional] Specify a secondary currency for each account.move.line linked to this account. --> + <field name="currency_id" ref="..."/> + + <!-- [Optional] Boolean to allow the user to reconcile entries in this account. True by default. --> + <field name="reconcile" eval="..."/> + + <!-- [Optional] Specify a group for this account. --> + <field name="group_id" ref="..."> + + <!-- [Optional] Specify some tags. --> + <field name="tag_ids" eval="..."> + </record> + +Some of the described fields above deserve a bit more explanation. + +The ``user_type_id`` field requires a value of type ``account.account.type``. +Although some additional types could be created in a localization module, we encourage the usage of the existing types in the `account/data/data_account_type.xml <https://github.com/odoo/odoo/blob/11.0/addons/account/data/data_account_type.xml>`_ file. +The usage of these generic types ensures the generic reports working correctly in addition to those that you could create in your localization module. + +.. warning:: + + Avoid the usage of liquidity ``account.account.type``! + Indeed, the bank & cash accounts are created directly at the installation of the localization module and then, are linked to an ``account.journal``. + +.. warning:: + + Only one account of type payable/receivable is enough. + +Although the ``tag_ids`` field is optional, this one remains a very powerful feature. +Indeed, this one allows you to define some tags for your accounts to spread them correctly on your reports. +For example, suppose you want to create a financial report having multiple lines but you have no way to find a rule to dispatch the accounts according their ``code`` or ``name``. +The solution is the usage of tags, one for each report line, to spread and aggregate your accounts like you want. + +Like any other record, a tag can be created with the following xml structure: + +.. code-block:: xml + + <record id="..." model="account.account.tag"> + <!-- [Required] Specify the name to display for this tag. --> + <field name="name">...</field> + + <!-- [Optional] Define a scope for this applicability. + The available keys are 'accounts' and 'taxes' but 'accounts' is the default value. --> + <field name="applicability">...</field> + </record> + +As you can well imagine with the usage of tags, this feature can also be used with taxes. + +An examples coming from the ``l10n_be`` module: + +.. code-block:: xml + + <record id="a4000" model="account.account.template"> + <field name="name">Clients</field> + <field name="code">4000</field> + <field name="user_type_id" ref="account.data_account_type_receivable"/> + <field name="chart_template_id" ref="l10nbe_chart_template"/> + </record> + +.. warning:: + + Don't create too much accounts: 200-300 is enough. + +Adding a new tax to my Chart of Accounts +######################################## + +To create a new tax record, you just need to follow the same process as the creation of accounts. +The only difference being that you must use the ``account.tax.template`` model. + +.. code-block:: xml + + <record id="..." model="account.tax.template"> + <!-- [Required] Specify the name to display for this tax. --> + <field name="name">...</field> + + <!-- [Required] Specify the amount. + E.g. 7 with fixed amount_type means v + 7 if v is the amount on which the tax is applied. + If amount_type is 'percent', the tax amount is v * 0.07. --> + <field name="amount" eval="..."/> + + <!-- [Required] Set the CoA owning this tax. --> + <field name="chart_template_id" ref="..."/> + + <!-- [Required/Optional] Define an account if the tax is not a group of taxes. --> + <field name="account_id" ref="..."/> + + <!-- [Required/Optional] Define an refund account if the tax is not a group of taxes. --> + <field name="refund_account_id" ref="..."/> + + <!-- [Optional] Define the tax's type. + 'sale', 'purchase' or 'none' are the allowed values. 'sale' is the default value. + 'adjustment' is also available to do some tax adjustments. + Note: 'none' means a tax can't be used by itself, however it can still be used in a group. --> + <field name="type_tax_use">...</field> + + <!-- [Optional] Define the type of amount: + 'group' for a group of taxes, 'fixed' for a tax with a fixed amount or 'percent' for a classic percentage of price. + By default, the type of amount is percentage. --> + <field name="amount_type" eval="..."/> + + <!-- [Optional] Define some children taxes. + /!\ Should be used only with an amount_type with 'group' set. --> + <field name="children_tax_ids" eval="..."/> + + <!-- [Optional] The sequence field is used to define order in which the tax lines are applied. + By default, sequence = 1. --> + <field name="sequence" eval="..."/> + + <!-- [Optional] Specify a short text to be displayed on invoices. + For example, a tax named "15% on Services" can have the following label on invoice "15%". --> + <field name="description">...</field> + + <!-- [Optional] Boolean that indicates if the amount should be considered as included in price. False by default. + E.g. Suppose v = 132 and a tax amount of 20. + If price_include = False, the computed amount will be 132 * 0.2 = 26.4. + If price_include = True, the computed amount will be 132 - (132 / 1.2) = 132 - 110 = 22. --> + <field name="price_include" eval="..."/> + + <!-- [Optional] Boolean to set to include the amount to the base. False by default. + If True, the subsequent taxes will be computed based on the base tax amount plus the amount of this tax. + E.g. suppose v = 100, t1, a tax of 10% and another tax t2 with 20%. + If t1 doesn't affects the base, + t1 amount = 100 * 0.1 = 10 and t2 amount = 100 * 0.2 = 20. + If t1 affects the base, + t1 amount = 100 * 0.1 = 10 and t2 amount = 110 * 0.2 = 22. --> + <field name="include_base_amount" eval="..."/> + + <!-- [Optional] Boolean false by default. + If set, the amount computed by this tax will be assigned to the same analytic account as the invoice line (if any). --> + <field name="analytic" eval="..."/> + + <!-- [Optional] Specify some tags. + These tags must have 'taxes' as applicability. + See the previous section for more details. --> + <field name="tag_ids" eval="..."> + + <!-- [Optional] Define a tax group used to display the sums of taxes in the invoices. --> + <field name="tax_group_id" ref="..."/> + + <!-- [Optional] Define the tax exigibility either based on invoice ('on_invoice' value) or + either based on payment using the 'on_payment' key. + The default value is 'on_invoice'. --> + <field name="tax_exigibility">...</field> + + <!-- [Optional] Define a cash basis account in case of tax exigibility 'on_payment'. --> + <field name="cash_basis_account" red="..."/> + </record> + +An example found in the ``l10n_pl`` module: + +.. code-block:: xml + + <record id="vp_leas_sale" model="account.tax.template"> + <field name="chart_template_id" ref="pl_chart_template"/> + <field name="name">VAT - leasing pojazdu(sale)</field> + <field name="description">VLP</field> + <field name="amount">1.00</field> + <field name="sequence" eval="1"/> + <field name="amount_type">group</field> + <field name="type_tax_use">sale</field> + <field name="children_tax_ids" eval="[(6, 0, [ref('vp_leas_sale_1'), ref('vp_leas_sale_2')])]"/> + <field name="tag_ids" eval="[(6,0,[ref('l10n_pl.tag_pl_21')])]"/> + <field name="tax_group_id" ref="tax_group_vat_23"/> + </record> + + +Adding a new fiscal position to my Chart of Accounts +#################################################### + +.. note:: + If you need more information about what is a fiscal position and how it works in Odoo, please refer to `How to adapt taxes to my customer status or localization <https://www.odoo.com/documentation/user/online/accounting/others/taxes/application.html>`_. + +To create a new fiscal position, simply use the ``account.fiscal.position.template`` model: + +.. code-block:: xml + + <record id="..." model="account.fiscal.position.template"> + <!-- [Required] Specify the name to display for this fiscal position. --> + <field name="name">...</field> + + <!-- [Required] Set the CoA owning this fiscal position. --> + <field name="chart_template_id" ref="..."/> + + <!-- [Optional] Add some additional notes. --> + <field name="note">...</field> + </record> + +Adding the properties to my Chart of Accounts +############################################# + +When the whole accounts are generated, you have the possibility to override the newly generated chart of accounts by adding some properties that correspond to default accounts used in certain situations. +This must be done after the creation of accounts before each one must be linked to the chart of accounts. + +.. code-block:: xml + + <?xml version="1.0" encoding="utf-8"?> + <odoo> + <record id="l10n_xx_chart_template" model="account.chart.template"> + + <!-- Define receivable/payable accounts. --> + <field name="property_account_receivable_id" ref="..."/> + <field name="property_account_payable_id" ref="..."/> + + <!-- Define categories of expense/income account. --> + <field name="property_account_expense_categ_id" ref="..."/> + <field name="property_account_income_categ_id" ref="..."/> + + <!-- Define input/output accounts for stock valuation. --> + <field name="property_stock_account_input_categ_id" ref="..."/> + <field name="property_stock_account_output_categ_id" ref="..."/> + + <!-- Define an account template for stock valuation. --> + <field name="property_stock_valuation_account_id" ref="..."/> + + <!-- Define loss/gain exchange rate accounts. --> + <field name="expense_currency_exchange_account_id" ref="..."/> + <field name="income_currency_exchange_account_id" ref="..."/> + + <!-- Define a transfer account. --> + <field name="transfer_account_id" ref="..."/> + </record> + </odoo> + +For example, let's come back to the Belgium PCMN. This chart of accounts is override in this way to add some properties. + +.. code-block:: xml + + <record id="l10nbe_chart_template" model="account.chart.template"> + <field name="property_account_receivable_id" ref="a4000"/> + <field name="property_account_payable_id" ref="a440"/> + <field name="property_account_expense_categ_id" ref="a600"/> + <field name="property_account_income_categ_id" ref="a7010"/> + <field name="expense_currency_exchange_account_id" ref="a654"/> + <field name="income_currency_exchange_account_id" ref="a754"/> + <field name="transfer_account_id" ref="trans"/> + </record> + +How to create a new bank operation model? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. note:: + How a bank operation model works exactly in Odoo? See `Configure model of entries <https://www.odoo.com/documentation/user/online/accounting/bank/reconciliation/configure.html>`_. + +Since ``V10``, a new feature is available in the bank statement reconciliation widget: the bank operation model. +This allows the user to pre-fill some accounting entries with a single click. +The creation of an ``account.reconcile.model.template`` record is quite easy: + +.. code-block:: xml + + <record id="..." model="account.reconcile.model.template"> + <!-- [Required] Specify the name to display. --> + <field name="name">...</field> + + <!-- [Required] Set the CoA owning this. --> + <field name="chart_template_id" ref="..."/> + + <!-- [Optional] Set a sequence number defining the order in which it will be displayed. + By default, the sequence is 10. --> + <field name="sequence" eval="..."/> + + <!-- [Optional] Define an account. --> + <field name="account_id" ref="..."/> + + <!-- [Optional] Define a label to be added to the journal item. --> + <field name="label">...</field> + + <!-- [Optional] Define the type of amount_type, either 'fixed' or 'percentage'. + The last one is the default value. --> + <field name="amount_type">...</field> + + <!-- [Optional] Define the balance amount on which this model will be applied to (100 by default). + Fixed amount will count as a debit if it is negative, as a credit if it is positive. --> + <field name="amount">...</field> + + <!-- [Optional] Define eventually a tax. --> + <field name="tax_id" ref="..."/> + + <!-- [Optional] The sames fields are available twice. + To enable a second journal line, you can set this field to true and + fill the fields accordingly. --> + <field name="has_second_line" eval="..."/> + <field name="second_account_id" ref="..."/> + <field name="second_label">...</field> + <field name="second_amount_type">...</field> + <field name="second_amount">...</field> + <field name="second_tax_id" ref="..."/> + </record> + +How to create a new dynamic report? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you need to add some reports on your localization, you need to create a new module named **l10n_xx_reports**. +Furthermore, this additional module must be present in the ``enterprise`` repository and must have at least two dependencies, +one to bring all the stuff for your localization module and one more, ``account_reports``, to design dynamic reports. + +.. code-block:: py + + 'depends': ['l10n_xx', 'account_reports'], + +Once it's done, you can start the creation of your report statements. The documentation is available in the following `slides <https://www.odoo.com/slides/slide/how-to-create-custom-accounting-report-415>`_. diff --git a/odoo/addons/base/models/assetsbundle.py b/odoo/addons/base/models/assetsbundle.py index 37657d3fdfa..de9f9f75eff 100644 --- a/odoo/addons/base/models/assetsbundle.py +++ b/odoo/addons/base/models/assetsbundle.py @@ -152,7 +152,7 @@ class AssetsBundle(object): Not really a full checksum. We compute a SHA1 on the rendered bundle + max linked files last_modified date """ - check = u"%s%s%s" % (json.dumps(self.files), u",".join(self.remains), self.last_modified) + check = u"%s%s%s" % (json.dumps(self.files, sort_keys=True), u",".join(self.remains), self.last_modified) return hashlib.sha1(check.encode('utf-8')).hexdigest() def clean_attachments(self, type): diff --git a/odoo/addons/base/models/ir_actions.py b/odoo/addons/base/models/ir_actions.py index 982a0364089..776a098e453 100644 --- a/odoo/addons/base/models/ir_actions.py +++ b/odoo/addons/base/models/ir_actions.py @@ -372,7 +372,7 @@ class IrActionsServer(models.Model): oldname='srcmodel_id', help="Model for record creation / update. Set this field only to specify a different model than the base model.") crud_model_name = fields.Char(related='crud_model_id.name', readonly=True) link_field_id = fields.Many2one('ir.model.fields', string='Link using field', - help="Provide the field used to link the newly created record" + help="Provide the field used to link the newly created record " "on the record on used by the server action.") fields_lines = fields.One2many('ir.server.object.lines', 'server_id', string='Value Mapping', copy=True) @@ -695,7 +695,7 @@ class IrActionsActClient(models.Model): res_model = fields.Char(string='Destination Model', help="Optional model, mostly used for needactions.") context = fields.Char(string='Context Value', default="{}", required=True, help="Context dictionary as Python expression, empty by default (Default: {})") params = fields.Binary(compute='_compute_params', inverse='_inverse_params', string='Supplementary arguments', - help="Arguments sent to the client along with" + help="Arguments sent to the client along with " "the view tag") params_store = fields.Binary(string='Params storage', readonly=True) diff --git a/odoo/addons/base/models/ir_mail_server.py b/odoo/addons/base/models/ir_mail_server.py index 0a08993d639..affbaacc5c8 100644 --- a/odoo/addons/base/models/ir_mail_server.py +++ b/odoo/addons/base/models/ir_mail_server.py @@ -144,7 +144,7 @@ class IrMailServer(models.Model): "- TLS (STARTTLS): TLS encryption is requested at start of SMTP session (Recommended)\n" "- SSL/TLS: SMTP sessions are encrypted with SSL/TLS through a dedicated port (default: 465)") smtp_debug = fields.Boolean(string='Debugging', help="If enabled, the full output of SMTP sessions will " - "be written to the server log at DEBUG level" + "be written to the server log at DEBUG level " "(this is very verbose and may include confidential info!)") sequence = fields.Integer(string='Priority', default=10, help="When no specific mail server is requested for a mail, the highest priority one " "is used. Default priority is 10 (smaller number = higher priority)") @@ -221,8 +221,8 @@ class IrMailServer(models.Model): if smtp_encryption == 'ssl': if 'SMTP_SSL' not in smtplib.__all__: raise UserError( - _("Your OpenERP Server does not support SMTP-over-SSL. " - "You could use STARTTLS instead." + _("Your Odoo Server does not support SMTP-over-SSL. " + "You could use STARTTLS instead. " "If SSL is needed, an upgrade to Python 2.6 on the server-side " "should do the trick.")) connection = smtplib.SMTP_SSL(smtp_server, smtp_port) diff --git a/odoo/addons/base/models/ir_model.py b/odoo/addons/base/models/ir_model.py index 3f924843bef..af51ddb547f 100644 --- a/odoo/addons/base/models/ir_model.py +++ b/odoo/addons/base/models/ir_model.py @@ -364,7 +364,7 @@ class IrModelFields(models.Model): raise ValueError(selection) except Exception: _logger.info('Invalid selection list definition for fields.selection', exc_info=True) - raise UserError(_("The Selection Options expression is not a valid Pythonic expression." + raise UserError(_("The Selection Options expression is not a valid Pythonic expression. " "Please provide an expression in the [('key','Label'), ...] format.")) @api.constrains('name', 'state') diff --git a/odoo/addons/base/models/ir_module.py b/odoo/addons/base/models/ir_module.py index 6fe4bbf525e..073eedfb535 100644 --- a/odoo/addons/base/models/ir_module.py +++ b/odoo/addons/base/models/ir_module.py @@ -418,17 +418,19 @@ class Module(models.Model): todo = todo.mapped('dependencies_id.depend_id') return result - for category in install_mods.mapped('category_id').filtered('exclusive'): - # the installation is valid if all installed modules in category - # correspond to one module and all its dependencies in category - category_mods = install_mods.filtered(lambda mod: mod.category_id == category) - if not any(closure(module) & category_mods == category_mods - for module in category_mods): + exclusives = self.env['ir.module.category'].search([('exclusive', '=', True)]) + for category in exclusives: + # retrieve installed modules in category and sub-categories + categories = category.search([('id', 'child_of', category.ids)]) + modules = install_mods.filtered(lambda mod: mod.category_id in categories) + # the installation is valid if all installed modules in categories + # belong to the transitive dependencies of one of them + if modules and not any(modules <= closure(module) for module in modules): msg = _('You are trying to install incompatible modules in category "%s":') labels = dict(self.fields_get(['state'])['state']['selection']) raise UserError("\n".join([msg % category.name] + [ "- %s (%s)" % (module.shortdesc, labels[module.state]) - for module in category_mods + for module in modules ])) return dict(ACTION_DICT, name=_('Install')) diff --git a/odoo/addons/base/models/ir_sequence.py b/odoo/addons/base/models/ir_sequence.py index 84c8be3af60..3f370f3fb4c 100644 --- a/odoo/addons/base/models/ir_sequence.py +++ b/odoo/addons/base/models/ir_sequence.py @@ -96,7 +96,7 @@ class IrSequence(models.Model): def _set_number_next_actual(self): for seq in self: - seq.write({'number_next': seq.number_next_actual or 0}) + seq.write({'number_next': seq.number_next_actual or 1}) @api.model def _get_current_sequence(self): @@ -119,7 +119,7 @@ class IrSequence(models.Model): implementation = fields.Selection([('standard', 'Standard'), ('no_gap', 'No gap')], string='Implementation', required=True, default='standard', help="Two sequence object implementations are offered: Standard " - "and 'No gap'. The later is slower than the former but forbids any" + "and 'No gap'. The later is slower than the former but forbids any " "gap in the sequence (while they are possible in the former).") active = fields.Boolean(default=True) prefix = fields.Char(help="Prefix value of the record for the sequence", trim=False) @@ -327,7 +327,7 @@ class IrSequenceDateRange(models.Model): def _set_number_next_actual(self): for seq in self: - seq.write({'number_next': seq.number_next_actual or 0}) + seq.write({'number_next': seq.number_next_actual or 1}) @api.model def default_get(self, fields): diff --git a/odoo/addons/base/models/res_partner.py b/odoo/addons/base/models/res_partner.py index 80868c73c71..996efde0721 100644 --- a/odoo/addons/base/models/res_partner.py +++ b/odoo/addons/base/models/res_partner.py @@ -509,7 +509,7 @@ class Partner(models.Model): result = True # To write in SUPERUSER on field is_company and avoid access rights problems. if 'is_company' in vals and self.user_has_groups('base.group_partner_manager') and not self.env.uid == SUPERUSER_ID: - result = super(Partner, self).sudo().write({'is_company': vals.get('is_company')}) + result = super(Partner, self.sudo()).write({'is_company': vals.get('is_company')}) del vals['is_company'] result = result and super(Partner, self).write(vals) for partner in self: diff --git a/odoo/addons/base/models/res_users.py b/odoo/addons/base/models/res_users.py index 5f136959014..53a3438b21a 100644 --- a/odoo/addons/base/models/res_users.py +++ b/odoo/addons/base/models/res_users.py @@ -283,6 +283,13 @@ class Users(models.Model): raise ValidationError(_('The chosen company is not in the allowed companies for this user')) @api.multi + @api.constrains('action_id') + def _check_action_id(self): + action_open_website = self.env.ref('base.action_open_website', raise_if_not_found=False) + if action_open_website and any(user.action_id.id == action_open_website.id for user in self): + raise ValidationError(_('The "App Switcher" action cannot be selected as home action.')) + + @api.multi def read(self, fields=None, load='_classic_read'): if fields and self == self.env.user: for key in fields: diff --git a/odoo/tools/config.py b/odoo/tools/config.py index 43d11f7b532..3191f3c61ca 100644 --- a/odoo/tools/config.py +++ b/odoo/tools/config.py @@ -224,9 +224,9 @@ class configmanager(object): help="specify a custom database template to create a new database") parser.add_option_group(group) - group = optparse.OptionGroup(parser, "Internationalisation options", - "Use these options to translate Odoo to another language." - "See i18n section of the user manual. Option '-d' is mandatory." + group = optparse.OptionGroup(parser, "Internationalisation options. ", + "Use these options to translate Odoo to another language. " + "See i18n section of the user manual. Option '-d' is mandatory. " "Option '-l' is mandatory in case of importation" ) group.add_option('--load-language', dest="load_language", |
