summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.tx/config386
-rw-r--r--addons/account/models/account_invoice.py6
-rw-r--r--addons/account/models/account_move.py2
-rw-r--r--addons/account/static/src/scss/variables.scss10
-rw-r--r--addons/account/tests/test_fiscal_position.py24
-rw-r--r--addons/account/views/account.xml4
-rw-r--r--addons/account/views/account_view.xml2
-rw-r--r--addons/account/wizard/setup_wizards.py18
-rw-r--r--addons/account_analytic_default/views/account_analytic_default_view.xml2
-rw-r--r--addons/account_analytic_default_hr_expense/__init__.py4
-rw-r--r--addons/account_analytic_default_hr_expense/__manifest__.py16
-rw-r--r--addons/account_analytic_default_hr_expense/models/__init__.py6
-rw-r--r--addons/account_analytic_default_hr_expense/models/hr_expense.py16
-rw-r--r--addons/account_voucher/models/account_voucher.py2
-rw-r--r--addons/board/static/src/js/board_view.js2
-rw-r--r--addons/calendar/data/calendar_data.xml14
-rw-r--r--addons/calendar/models/calendar.py12
-rw-r--r--addons/calendar/tests/test_calendar.py2
-rw-r--r--addons/calendar/views/calendar_views.xml4
-rw-r--r--addons/calendar_sms/models/calendar.py2
-rw-r--r--addons/contacts/models/res_users.py5
-rw-r--r--addons/contacts/views/contact_views.xml15
-rw-r--r--addons/crm/models/crm_lead.py12
-rw-r--r--addons/crm/report/crm_opportunity_report_views.xml36
-rw-r--r--addons/crm/views/crm_lead_views.xml22
-rw-r--r--addons/crm/views/crm_stage_views.xml26
-rw-r--r--addons/crm/views/res_config_settings_views.xml3
-rw-r--r--addons/delivery/views/delivery_view.xml9
-rw-r--r--addons/digest/__manifest__.py3
-rw-r--r--addons/digest/models/digest.py3
-rw-r--r--addons/event/views/event_views.xml2
-rw-r--r--addons/fetchmail/models/fetchmail.py44
-rw-r--r--addons/fetchmail/views/fetchmail_views.xml24
-rw-r--r--addons/fleet/data/fleet_cars_data.xml180
-rw-r--r--addons/fleet/data/fleet_demo.xml225
-rw-r--r--addons/fleet/models/fleet_vehicle.py2
-rw-r--r--addons/fleet/views/fleet_vehicle_model_views.xml20
-rw-r--r--addons/fleet/views/fleet_vehicle_views.xml8
-rw-r--r--addons/google_calendar/models/google_calendar.py6
-rw-r--r--addons/hr/data/hr_demo.xml4
-rw-r--r--addons/hr/models/res_config_settings.py4
-rw-r--r--addons/hr/views/hr_views.xml22
-rw-r--r--addons/hr_attendance/views/hr_attendance_view.xml2
-rw-r--r--addons/hr_contract/models/hr_contract.py22
-rw-r--r--addons/hr_contract/views/hr_contract_views.xml22
-rw-r--r--addons/hr_expense/models/hr_expense.py6
-rw-r--r--addons/hr_expense/views/hr_expense_views.xml4
-rw-r--r--addons/hr_fleet/__init__.py5
-rw-r--r--addons/hr_fleet/__manifest__.py15
-rw-r--r--addons/hr_fleet/controllers/__init__.py4
-rw-r--r--addons/hr_fleet/controllers/main.py72
-rw-r--r--addons/hr_fleet/models/__init__.py5
-rw-r--r--addons/hr_fleet/models/employee.py39
-rw-r--r--addons/hr_fleet/models/fleet_vehicle_assignation_log.py25
-rw-r--r--addons/hr_fleet/views/employee_views.xml25
-rw-r--r--addons/hr_fleet/views/fleet_vehicle_views.xml14
-rw-r--r--addons/hr_gamification/views/gamification_views.xml2
-rw-r--r--addons/hr_holidays/__manifest__.py2
-rw-r--r--addons/hr_holidays/data/hr_holidays_data.xml12
-rw-r--r--addons/hr_holidays/data/hr_holidays_demo.xml514
-rw-r--r--addons/hr_holidays/data/mail_data.xml69
-rw-r--r--addons/hr_holidays/models/__init__.py1
-rw-r--r--addons/hr_holidays/models/hr.py7
-rw-r--r--addons/hr_holidays/models/hr_leave.py34
-rw-r--r--addons/hr_holidays/models/hr_leave_allocation.py11
-rw-r--r--addons/hr_holidays/models/hr_leave_type.py3
-rw-r--r--addons/hr_holidays/models/mail_message_subtype.py50
-rw-r--r--addons/hr_holidays/report/hr_leave_report.py10
-rw-r--r--addons/hr_holidays/report/hr_leave_reports.xml4
-rw-r--r--addons/hr_holidays/security/hr_holidays_security.xml24
-rw-r--r--addons/hr_holidays/tests/__init__.py1
-rw-r--r--addons/hr_holidays/tests/test_change_department.py70
-rw-r--r--addons/hr_holidays/views/hr_leave_allocation_views.xml9
-rw-r--r--addons/hr_holidays/views/hr_leave_type_views.xml13
-rw-r--r--addons/hr_holidays/views/hr_leave_views.xml16
-rw-r--r--addons/hr_holidays/views/hr_views.xml3
-rw-r--r--addons/hr_maintenance/models/equipment.py2
-rw-r--r--addons/hr_org_chart/static/src/scss/variables.scss12
-rw-r--r--addons/hr_org_chart/views/hr_templates.xml4
-rw-r--r--addons/hr_payroll/models/__init__.py1
-rw-r--r--addons/hr_payroll/models/browsable_object.py62
-rw-r--r--addons/hr_payroll/models/hr_contract.py9
-rw-r--r--addons/hr_payroll/models/hr_payslip.py155
-rw-r--r--addons/hr_payroll/models/hr_salary_rule.py14
-rw-r--r--addons/hr_payroll/views/hr_salary_rule_views.xml30
-rw-r--r--addons/hr_payroll/wizard/hr_payroll_payslips_by_employees.py7
-rw-r--r--addons/hr_payroll_account/views/hr_payroll_account_views.xml2
-rw-r--r--addons/hr_presence/__init__.py5
-rw-r--r--addons/hr_presence/__manifest__.py29
-rw-r--r--addons/hr_presence/controllers/__init__.py4
-rw-r--r--addons/hr_presence/controllers/bus_controller.py24
-rw-r--r--addons/hr_presence/data/ir_actions_server.xml16
-rw-r--r--addons/hr_presence/data/mail_data.xml22
-rw-r--r--addons/hr_presence/models/__init__.py7
-rw-r--r--addons/hr_presence/models/hr_employee.py164
-rw-r--r--addons/hr_presence/models/res_company.py13
-rw-r--r--addons/hr_presence/models/res_config_settings.py13
-rw-r--r--addons/hr_presence/models/res_users_log.py10
-rw-r--r--addons/hr_presence/views/hr_employee_views.xml35
-rw-r--r--addons/hr_presence/views/res_config_settings_views.xml44
-rw-r--r--addons/hr_recruitment/models/hr_job.py2
-rw-r--r--addons/hr_recruitment/models/hr_recruitment.py8
-rw-r--r--addons/hr_recruitment/views/hr_recruitment_views.xml20
-rw-r--r--addons/hr_recruitment_survey/data/survey_survey_data.xml16
-rw-r--r--addons/hr_timesheet/data/hr_timesheet_data.xml2
-rw-r--r--addons/hr_timesheet/models/project.py6
-rw-r--r--addons/iap/__manifest__.py3
-rw-r--r--addons/iap/static/src/img/iap_logo.svg1
-rw-r--r--addons/iap/static/src/js/dashboard.js26
-rw-r--r--addons/iap/static/src/xml/iap_templates.xml18
-rw-r--r--addons/l10n_ch/models/res_config_settings.py1
-rw-r--r--addons/l10n_ch/views/res_config_settings_views.xml9
-rw-r--r--addons/link_tracker/__init__.py4
-rw-r--r--addons/link_tracker/__manifest__.py11
-rw-r--r--addons/link_tracker/controller/__init__.py2
-rw-r--r--addons/link_tracker/models/__init__.py2
-rw-r--r--addons/link_tracker/models/link_tracker.py150
-rw-r--r--addons/link_tracker/views/link_tracker_views.xml (renamed from addons/link_tracker/views/link_tracker.xml)122
-rw-r--r--addons/lunch/views/lunch_views.xml10
-rw-r--r--addons/mail/data/mail_data.xml2
-rw-r--r--addons/mail/models/mail_activity.py2
-rw-r--r--addons/mail/models/mail_message.py2
-rw-r--r--addons/mail/models/mail_thread.py18
-rw-r--r--addons/mail/models/res_users.py4
-rw-r--r--addons/mail/static/src/js/emojis.js1
-rw-r--r--addons/mail/static/src/js/systray/systray_activity_menu.js19
-rw-r--r--addons/mail/static/tests/systray/systray_activity_menu_tests.js73
-rw-r--r--addons/mail/views/ir_actions_views.xml8
-rw-r--r--addons/mail/views/mail_tracking_views.xml22
-rw-r--r--addons/mail/wizard/invite.py8
-rw-r--r--addons/maintenance/models/maintenance.py6
-rw-r--r--addons/mass_mailing/__manifest__.py1
-rw-r--r--addons/mass_mailing/controllers/main.py5
-rw-r--r--addons/mass_mailing/data/mass_mailing_demo.xml23
-rw-r--r--addons/mass_mailing/views/link_tracker_views.xml63
-rw-r--r--addons/mass_mailing/views/mass_mailing_blacklist_views.xml2
-rw-r--r--addons/mass_mailing/views/mass_mailing_views.xml20
-rw-r--r--addons/mass_mailing/views/mass_mailing_views_menus.xml12
-rw-r--r--addons/mass_mailing/views/res_config_settings_views.xml4
-rw-r--r--addons/mass_mailing/views/unsubscribe_templates.xml13
-rw-r--r--addons/mrp/models/mrp_workorder.py23
-rw-r--r--addons/mrp/models/stock_warehouse.py17
-rw-r--r--addons/mrp/report/report_stock_rule.py2
-rw-r--r--addons/mrp/views/mrp_workcenter_views.xml4
-rw-r--r--addons/note/data/res_users_data.xml2
-rw-r--r--addons/point_of_sale/models/pos_config.py23
-rw-r--r--addons/point_of_sale/models/pos_order.py2
-rw-r--r--addons/point_of_sale/static/src/js/models.js82
-rw-r--r--addons/point_of_sale/static/src/js/screens.js2
-rw-r--r--addons/point_of_sale/static/src/js/tests.js20
-rw-r--r--addons/point_of_sale/static/src/xml/pos.xml55
-rw-r--r--addons/point_of_sale/tests/test_frontend.py16
-rw-r--r--addons/point_of_sale/views/pos_config_view.xml7
-rw-r--r--addons/product/models/product.py8
-rw-r--r--addons/product/models/product_pricelist.py5
-rw-r--r--addons/product/tests/test_seller.py37
-rw-r--r--addons/product/views/product_views.xml5
-rw-r--r--addons/project/models/project.py38
-rw-r--r--addons/project/views/project_views.xml14
-rw-r--r--addons/purchase/models/purchase.py42
-rw-r--r--addons/purchase/models/res_partner.py2
-rw-r--r--addons/purchase/views/purchase_views.xml4
-rw-r--r--addons/purchase/views/res_partner_views.xml2
-rw-r--r--addons/purchase_mrp/models/purchase_mrp.py16
-rw-r--r--addons/purchase_requisition/data/purchase_requisition_data.xml2
-rw-r--r--addons/purchase_stock/models/purchase.py37
-rw-r--r--addons/purchase_stock/models/stock.py12
-rw-r--r--addons/repair/data/repair_demo.xml6
-rw-r--r--addons/repair/tests/test_repair.py4
-rw-r--r--addons/resource/data/resource_data.xml2
-rw-r--r--addons/sale/models/__init__.py1
-rw-r--r--addons/sale/models/product_pricelist.py13
-rw-r--r--addons/sale/models/sale.py10
-rw-r--r--addons/sale/wizard/sale_make_invoice_advance.py2
-rw-r--r--addons/sale_management/models/sale_order_template.py2
-rw-r--r--addons/sale_management/views/sale_order_views.xml2
-rw-r--r--addons/sale_stock/views/stock_config_settings_views.xml59
-rw-r--r--addons/sale_timesheet/models/project.py20
-rw-r--r--addons/sms/wizard/send_sms.py2
-rw-r--r--addons/stock/__init__.py17
-rw-r--r--addons/stock/__manifest__.py1
-rw-r--r--addons/stock/data/stock_data.xml45
-rw-r--r--addons/stock/data/stock_demo2.xml1
-rw-r--r--addons/stock/models/res_company.py109
-rw-r--r--addons/stock/models/stock_inventory.py2
-rw-r--r--addons/stock/models/stock_location.py11
-rw-r--r--addons/stock/models/stock_move.py6
-rw-r--r--addons/stock/models/stock_move_line.py2
-rw-r--r--addons/stock/models/stock_package_level.py19
-rw-r--r--addons/stock/models/stock_picking.py32
-rw-r--r--addons/stock/models/stock_warehouse.py88
-rw-r--r--addons/stock/tests/test_packing.py9
-rw-r--r--addons/stock/tests/test_stock_flow.py52
-rw-r--r--addons/stock/tests/test_warehouse.py150
-rw-r--r--addons/stock/views/stock_package_level_views.xml9
-rw-r--r--addons/stock/views/stock_picking_views.xml8
-rw-r--r--addons/stock/wizard/stock_backorder_confirmation.py7
-rw-r--r--addons/stock_account/models/stock.py4
-rw-r--r--addons/stock_landed_costs/models/stock_landed_cost.py2
-rw-r--r--addons/stock_picking_batch/models/stock_picking_batch.py2
-rw-r--r--addons/survey/__manifest__.py26
-rw-r--r--addons/survey/controllers/main.py12
-rw-r--r--addons/survey/data/ir_actions_data.xml16
-rw-r--r--addons/survey/data/survey_data.xml (renamed from addons/survey/data/survey_stages.xml)0
-rw-r--r--addons/survey/data/survey_demo_feedback.xml26
-rw-r--r--addons/survey/models/__init__.py6
-rw-r--r--addons/survey/models/survey.py1053
-rw-r--r--addons/survey/models/survey_page.py27
-rw-r--r--addons/survey/models/survey_question.py299
-rw-r--r--addons/survey/models/survey_stage.py21
-rw-r--r--addons/survey/models/survey_survey.py354
-rw-r--r--addons/survey/models/survey_user.py386
-rw-r--r--addons/survey/static/src/js/survey.js10
-rw-r--r--addons/survey/static/src/js/survey_result.js4
-rw-r--r--addons/survey/tests/test_survey.py32
-rw-r--r--addons/survey/views/assets.xml30
-rw-r--r--addons/survey/views/survey_menus.xml10
-rw-r--r--addons/survey/views/survey_page_views.xml71
-rw-r--r--addons/survey/views/survey_question_views.xml228
-rw-r--r--addons/survey/views/survey_result.xml346
-rw-r--r--addons/survey/views/survey_stage_views.xml19
-rw-r--r--addons/survey/views/survey_survey_views.xml182
-rw-r--r--addons/survey/views/survey_templates.xml389
-rw-r--r--addons/survey/views/survey_user_views.xml222
-rw-r--r--addons/survey/views/survey_views.xml732
-rw-r--r--addons/survey/wizard/__init__.py2
-rw-r--r--addons/survey/wizard/survey_mail_compose_message.py (renamed from addons/survey/wizard/survey_email_compose_message.py)2
-rw-r--r--addons/survey/wizard/survey_mail_compose_message_views.xml (renamed from addons/survey/wizard/survey_email_compose_message.xml)0
-rw-r--r--addons/test_mail/models/test_mail_models.py2
-rw-r--r--addons/test_mail/tests/test_message_track.py4
-rw-r--r--addons/test_mass_mailing/tests/test_mass_mailing.py9
-rw-r--r--addons/utm/__init__.py2
-rw-r--r--addons/utm/__manifest__.py12
-rw-r--r--addons/utm/models/__init__.py3
-rw-r--r--addons/utm/models/utm.py58
-rw-r--r--addons/utm/models/utm_mixin.py59
-rw-r--r--addons/utm/views/utm.xml110
-rw-r--r--addons/utm/views/utm_views.xml136
-rw-r--r--addons/web/static/lib/es5-shim/README.md155
-rw-r--r--addons/web/static/lib/es5-shim/es5-shim.js1320
-rw-r--r--addons/web/static/lib/es5-shim/es5-shim.map1
-rw-r--r--addons/web/static/lib/es5-shim/es5-shim.min.js4
-rw-r--r--addons/web/static/lib/es5-shim/package.json34
-rw-r--r--addons/web/static/src/js/boot.js2
-rw-r--r--addons/web/static/src/js/chrome/action_manager.js10
-rw-r--r--addons/web/static/src/js/chrome/action_manager_act_window.js33
-rw-r--r--addons/web/static/src/js/core/utils.js7
-rw-r--r--addons/web/static/src/js/views/abstract_controller.js108
-rw-r--r--addons/web/static/src/js/views/abstract_view.js52
-rw-r--r--addons/web/static/src/js/views/basic/basic_controller.js30
-rw-r--r--addons/web/static/src/js/views/basic/basic_model.js825
-rw-r--r--addons/web/static/src/js/views/basic/basic_renderer.js42
-rw-r--r--addons/web/static/src/js/views/form/form_controller.js94
-rw-r--r--addons/web/static/src/js/views/form/form_renderer.js17
-rw-r--r--addons/web/static/src/js/views/graph/graph_controller.js10
-rw-r--r--addons/web/static/src/js/views/graph/graph_renderer.js5
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_controller.js92
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_renderer.js31
-rw-r--r--addons/web/static/src/js/views/list/list_controller.js117
-rw-r--r--addons/web/static/src/js/views/list/list_renderer.js56
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_controller.js53
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_renderer.js7
-rw-r--r--addons/web/static/src/js/views/view_utils.js47
-rw-r--r--addons/web/static/src/js/widgets/model_field_selector.js40
-rw-r--r--addons/web/static/src/scss/model_field_selector.scss4
-rw-r--r--addons/web/static/src/xml/base.xml5
-rw-r--r--addons/web/static/tests/fields/relational_fields/field_many_to_many_tests.js1160
-rw-r--r--addons/web/static/tests/fields/relational_fields/field_many_to_one_tests.js3025
-rw-r--r--addons/web/static/tests/fields/relational_fields/field_one_to_many_tests.js8176
-rw-r--r--addons/web/static/tests/fields/relational_fields_tests.js11502
-rw-r--r--addons/web/static/tests/helpers/test_utils.js90
-rw-r--r--addons/web/static/tests/views/kanban_tests.js132
-rw-r--r--addons/web/static/tests/widgets/model_field_selector_tests.js31
-rw-r--r--addons/web/views/webclient_templates.xml5
-rw-r--r--addons/web_editor/models/ir_translation.py2
-rw-r--r--addons/web_editor/static/src/js/editor/snippets.editor.js20
-rw-r--r--addons/website/controllers/main.py8
-rw-r--r--addons/website/models/ir_qweb.py4
-rw-r--r--addons/website/models/res_partner.py4
-rw-r--r--addons/website/models/website.py4
-rw-r--r--addons/website/static/src/js/widgets/theme.js85
-rw-r--r--addons/website/static/src/scss/website.edit_mode.scss4
-rw-r--r--addons/website/static/src/xml/website.editor.xml60
-rw-r--r--addons/website/views/website_templates.xml55
-rw-r--r--addons/website/views/website_views.xml6
-rw-r--r--addons/website_crm_partner_assign/views/res_partner_views.xml10
-rw-r--r--addons/website_event/models/event.py4
-rw-r--r--addons/website_event_track/models/event_track.py4
-rw-r--r--addons/website_google_map/controllers/main.py2
-rw-r--r--addons/website_sale/data/demo.xml1
-rw-r--r--addons/website_sale/views/templates.xml2
-rw-r--r--debian/control4
-rw-r--r--debian/py3dist-overrides3
-rw-r--r--doc/cla/individual/brunojppb.md11
-rw-r--r--doc/reference/javascript_reference.rst5
-rw-r--r--doc/reference/mixins.rst2
-rw-r--r--doc/reference/orm.rst4
-rw-r--r--odoo/addons/base/data/res_country_data.xml1
-rw-r--r--odoo/addons/base/data/res_users_data.xml1
-rw-r--r--odoo/addons/base/models/assetsbundle.py49
-rw-r--r--odoo/addons/base/models/ir_actions.py12
-rw-r--r--odoo/addons/base/models/ir_actions_report.py19
-rw-r--r--odoo/addons/base/models/ir_mail_server.py1
-rw-r--r--odoo/addons/base/models/ir_model.py1
-rw-r--r--odoo/addons/base/models/ir_qweb_fields.py2
-rw-r--r--odoo/addons/base/models/ir_rule.py2
-rw-r--r--odoo/addons/base/models/ir_translation.py8
-rw-r--r--odoo/addons/base/models/res_users.py58
-rw-r--r--odoo/addons/base/tests/test_ir_actions.py6
-rw-r--r--odoo/addons/base/tests/test_views.py12
-rw-r--r--odoo/addons/base/views/ir_actions_views.xml10
-rw-r--r--odoo/addons/base/views/ir_default_views.xml6
-rw-r--r--odoo/addons/base/views/ir_model_views.xml24
-rw-r--r--odoo/addons/base/views/res_users_views.xml9
-rw-r--r--odoo/addons/test_assetsbundle/tests/test_assetsbundle.py99
-rw-r--r--odoo/addons/test_convert/__init__.py10
-rw-r--r--odoo/addons/test_convert/__manifest__.py3
-rw-r--r--odoo/addons/test_convert/ir.model.access.csv2
-rw-r--r--odoo/addons/test_convert/tests/__init__.py1
-rw-r--r--odoo/addons/test_convert/tests/test_env.py166
-rw-r--r--odoo/addons/test_documentation_examples/delegation.py35
-rw-r--r--odoo/addons/test_documentation_examples/ir.model.access.csv6
-rw-r--r--odoo/addons/test_documentation_examples/tests/test_delegation.py28
-rw-r--r--odoo/addons/test_impex/tests/test_load.py4
-rw-r--r--odoo/addons/test_performance/models/models.py6
-rw-r--r--odoo/addons/test_performance/tests/test_performance.py269
-rw-r--r--odoo/fields.py373
-rw-r--r--odoo/import_xml.rng57
-rw-r--r--odoo/release.py2
-rw-r--r--odoo/service/db.py4
-rw-r--r--odoo/tests/common.py72
-rw-r--r--odoo/tools/__init__.py2
-rw-r--r--odoo/tools/convert.py388
-rw-r--r--setup.py2
334 files changed, 20648 insertions, 18535 deletions
diff --git a/.tx/config b/.tx/config
index eb03dc3998e..128c19e32aa 100644
--- a/.tx/config
+++ b/.tx/config
@@ -2,967 +2,967 @@
host = https://www.transifex.com
type = PO
-[odoo-12.base]
+[odoo-master.base]
file_filter = odoo/addons/base/i18n/<lang>.po
source_file = odoo/addons/base/i18n/base.pot
source_lang = en
-[odoo-12.account]
+[odoo-master.account]
file_filter = addons/account/i18n/<lang>.po
source_file = addons/account/i18n/account.pot
source_lang = en
-[odoo-12.account_analytic_default]
+[odoo-master.account_analytic_default]
file_filter = addons/account_analytic_default/i18n/<lang>.po
source_file = addons/account_analytic_default/i18n/account_analytic_default.pot
source_lang = en
-[odoo-12.account_bank_statement_import]
+[odoo-master.account_bank_statement_import]
file_filter = addons/account_bank_statement_import/i18n/<lang>.po
source_file = addons/account_bank_statement_import/i18n/account_bank_statement_import.pot
source_lang = en
-[odoo-12.account_cancel]
+[odoo-master.account_cancel]
file_filter = addons/account_cancel/i18n/<lang>.po
source_file = addons/account_cancel/i18n/account_cancel.pot
source_lang = en
-[odoo-12.account_check_printing]
+[odoo-master.account_check_printing]
file_filter = addons/account_check_printing/i18n/<lang>.po
source_file = addons/account_check_printing/i18n/account_check_printing.pot
source_lang = en
-[odoo-12.account_payment]
+[odoo-master.account_payment]
file_filter = addons/account_payment/i18n/<lang>.po
source_file = addons/account_payment/i18n/account_payment.pot
source_lang = en
-[odoo-12.account_tax_python]
+[odoo-master.account_tax_python]
file_filter = addons/account_tax_python/i18n/<lang>.po
source_file = addons/account_tax_python/i18n/account_tax_python.pot
source_lang = en
-[odoo-12.account_test]
+[odoo-master.account_test]
file_filter = addons/account_test/i18n/<lang>.po
source_file = addons/account_test/i18n/account_test.pot
source_lang = en
-[odoo-12.account_voucher]
+[odoo-master.account_voucher]
file_filter = addons/account_voucher/i18n/<lang>.po
source_file = addons/account_voucher/i18n/account_voucher.pot
source_lang = en
-[odoo-12.analytic]
+[odoo-master.analytic]
file_filter = addons/analytic/i18n/<lang>.po
source_file = addons/analytic/i18n/analytic.pot
source_lang = en
-[odoo-12.association]
+[odoo-master.association]
file_filter = addons/association/i18n/<lang>.po
source_file = addons/association/i18n/association.pot
source_lang = en
-[odoo-12.auth_ldap]
+[odoo-master.auth_ldap]
file_filter = addons/auth_ldap/i18n/<lang>.po
source_file = addons/auth_ldap/i18n/auth_ldap.pot
source_lang = en
-[odoo-12.auth_oauth]
+[odoo-master.auth_oauth]
file_filter = addons/auth_oauth/i18n/<lang>.po
source_file = addons/auth_oauth/i18n/auth_oauth.pot
source_lang = en
-[odoo-12.auth_password_policy]
+[odoo-master.auth_password_policy]
file_filter = addons/auth_password_policy/i18n/<lang>.po
source_file = addons/auth_password_policy/i18n/auth_password_policy.pot
source_lang = en
-[odoo-12.auth_password_policy_signup]
+[odoo-master.auth_password_policy_signup]
file_filter = addons/auth_password_policy_signup/i18n/<lang>.po
source_file = addons/auth_password_policy_signup/i18n/auth_password_policy_signup.pot
source_lang = en
-[odoo-12.auth_signup]
+[odoo-master.auth_signup]
file_filter = addons/auth_signup/i18n/<lang>.po
source_file = addons/auth_signup/i18n/auth_signup.pot
source_lang = en
-[odoo-12.barcodes]
+[odoo-master.barcodes]
file_filter = addons/barcodes/i18n/<lang>.po
source_file = addons/barcodes/i18n/barcodes.pot
source_lang = en
-[odoo-12.base_address_city]
+[odoo-master.base_address_city]
file_filter = addons/base_address_city/i18n/<lang>.po
source_file = addons/base_address_city/i18n/base_address_city.pot
source_lang = en
-[odoo-12.base_address_extended]
+[odoo-master.base_address_extended]
file_filter = addons/base_address_extended/i18n/<lang>.po
source_file = addons/base_address_extended/i18n/base_address_extended.pot
source_lang = en
-[odoo-12.base_automation]
+[odoo-master.base_automation]
file_filter = addons/base_automation/i18n/<lang>.po
source_file = addons/base_automation/i18n/base_automation.pot
source_lang = en
-[odoo-12.base_gengo]
+[odoo-master.base_gengo]
file_filter = addons/base_gengo/i18n/<lang>.po
source_file = addons/base_gengo/i18n/base_gengo.pot
source_lang = en
-[odoo-12.base_geolocalize]
+[odoo-master.base_geolocalize]
file_filter = addons/base_geolocalize/i18n/<lang>.po
source_file = addons/base_geolocalize/i18n/base_geolocalize.pot
source_lang = en
-[odoo-12.base_iban]
+[odoo-master.base_iban]
file_filter = addons/base_iban/i18n/<lang>.po
source_file = addons/base_iban/i18n/base_iban.pot
source_lang = en
-[odoo-12.base_import]
+[odoo-master.base_import]
file_filter = addons/base_import/i18n/<lang>.po
source_file = addons/base_import/i18n/base_import.pot
source_lang = en
-[odoo-12.base_import_module]
+[odoo-master.base_import_module]
file_filter = addons/base_import_module/i18n/<lang>.po
source_file = addons/base_import_module/i18n/base_import_module.pot
source_lang = en
-[odoo-12.base_setup]
+[odoo-master.base_setup]
file_filter = addons/base_setup/i18n/<lang>.po
source_file = addons/base_setup/i18n/base_setup.pot
source_lang = en
-[odoo-12.base_sparse_field]
+[odoo-master.base_sparse_field]
file_filter = addons/base_sparse_field/i18n/<lang>.po
source_file = addons/base_sparse_field/i18n/base_sparse_field.pot
source_lang = en
-[odoo-12.base_vat]
+[odoo-master.base_vat]
file_filter = addons/base_vat/i18n/<lang>.po
source_file = addons/base_vat/i18n/base_vat.pot
source_lang = en
-[odoo-12.board]
+[odoo-master.board]
file_filter = addons/board/i18n/<lang>.po
source_file = addons/board/i18n/board.pot
source_lang = en
-[odoo-12.bus]
+[odoo-master.bus]
file_filter = addons/bus/i18n/<lang>.po
source_file = addons/bus/i18n/bus.pot
source_lang = en
-[odoo-12.calendar]
+[odoo-master.calendar]
file_filter = addons/calendar/i18n/<lang>.po
source_file = addons/calendar/i18n/calendar.pot
source_lang = en
-[odoo-12.calendar_sms]
+[odoo-master.calendar_sms]
file_filter = addons/calendar_sms/i18n/<lang>.po
source_file = addons/calendar_sms/i18n/calendar_sms.pot
source_lang = en
-[odoo-12.contacts]
+[odoo-master.contacts]
file_filter = addons/contacts/i18n/<lang>.po
source_file = addons/contacts/i18n/contacts.pot
source_lang = en
-[odoo-12.crm]
+[odoo-master.crm]
file_filter = addons/crm/i18n/<lang>.po
source_file = addons/crm/i18n/crm.pot
source_lang = en
-[odoo-12.crm_livechat]
+[odoo-master.crm_livechat]
file_filter = addons/crm_livechat/i18n/<lang>.po
source_file = addons/crm_livechat/i18n/crm_livechat.pot
source_lang = en
-[odoo-12.crm_phone_validation]
+[odoo-master.crm_phone_validation]
file_filter = addons/crm_phone_validation/i18n/<lang>.po
source_file = addons/crm_phone_validation/i18n/crm_phone_validation.pot
source_lang = en
-[odoo-12.crm_project]
+[odoo-master.crm_project]
file_filter = addons/crm_project/i18n/<lang>.po
source_file = addons/crm_project/i18n/crm_project.pot
source_lang = en
-[odoo-12.decimal_precision]
+[odoo-master.decimal_precision]
file_filter = addons/decimal_precision/i18n/<lang>.po
source_file = addons/decimal_precision/i18n/decimal_precision.pot
source_lang = en
-[odoo-12.delivery]
+[odoo-master.delivery]
file_filter = addons/delivery/i18n/<lang>.po
source_file = addons/delivery/i18n/delivery.pot
source_lang = en
-[odoo-12.digest]
+[odoo-master.digest]
file_filter = addons/digest/i18n/<lang>.po
source_file = addons/digest/i18n/digest.pot
source_lang = en
-[odoo-12.document]
+[odoo-master.document]
file_filter = addons/document/i18n/<lang>.po
source_file = addons/document/i18n/document.pot
source_lang = en
-[odoo-12.event]
+[odoo-master.event]
file_filter = addons/event/i18n/<lang>.po
source_file = addons/event/i18n/event.pot
source_lang = en
-[odoo-12.event_sale]
+[odoo-master.event_sale]
file_filter = addons/event_sale/i18n/<lang>.po
source_file = addons/event_sale/i18n/event_sale.pot
source_lang = en
-[odoo-12.fetchmail]
+[odoo-master.fetchmail]
file_filter = addons/fetchmail/i18n/<lang>.po
source_file = addons/fetchmail/i18n/fetchmail.pot
source_lang = en
-[odoo-12.fleet]
+[odoo-master.fleet]
file_filter = addons/fleet/i18n/<lang>.po
source_file = addons/fleet/i18n/fleet.pot
source_lang = en
-[odoo-12.gamification]
+[odoo-master.gamification]
file_filter = addons/gamification/i18n/<lang>.po
source_file = addons/gamification/i18n/gamification.pot
source_lang = en
-[odoo-12.gamification_sale_crm]
+[odoo-master.gamification_sale_crm]
file_filter = addons/gamification_sale_crm/i18n/<lang>.po
source_file = addons/gamification_sale_crm/i18n/gamification_sale_crm.pot
source_lang = en
-[odoo-12.google_account]
+[odoo-master.google_account]
file_filter = addons/google_account/i18n/<lang>.po
source_file = addons/google_account/i18n/google_account.pot
source_lang = en
-[odoo-12.google_calendar]
+[odoo-master.google_calendar]
file_filter = addons/google_calendar/i18n/<lang>.po
source_file = addons/google_calendar/i18n/google_calendar.pot
source_lang = en
-[odoo-12.google_drive]
+[odoo-master.google_drive]
file_filter = addons/google_drive/i18n/<lang>.po
source_file = addons/google_drive/i18n/google_drive.pot
source_lang = en
-[odoo-12.google_spreadsheet]
+[odoo-master.google_spreadsheet]
file_filter = addons/google_spreadsheet/i18n/<lang>.po
source_file = addons/google_spreadsheet/i18n/google_spreadsheet.pot
source_lang = en
-[odoo-12.hr]
+[odoo-master.hr]
file_filter = addons/hr/i18n/<lang>.po
source_file = addons/hr/i18n/hr.pot
source_lang = en
-[odoo-12.hr_attendance]
+[odoo-master.hr_attendance]
file_filter = addons/hr_attendance/i18n/<lang>.po
source_file = addons/hr_attendance/i18n/hr_attendance.pot
source_lang = en
-[odoo-12.hr_contract]
+[odoo-master.hr_contract]
file_filter = addons/hr_contract/i18n/<lang>.po
source_file = addons/hr_contract/i18n/hr_contract.pot
source_lang = en
-[odoo-12.hr_expense]
+[odoo-master.hr_expense]
file_filter = addons/hr_expense/i18n/<lang>.po
source_file = addons/hr_expense/i18n/hr_expense.pot
source_lang = en
-[odoo-12.hr_expense_check]
+[odoo-master.hr_expense_check]
file_filter = addons/hr_expense_check/i18n/<lang>.po
source_file = addons/hr_expense_check/i18n/hr_expense_check.pot
source_lang = en
-[odoo-12.hr_gamification]
+[odoo-master.hr_gamification]
file_filter = addons/hr_gamification/i18n/<lang>.po
source_file = addons/hr_gamification/i18n/hr_gamification.pot
source_lang = en
-[odoo-12.hr_holidays]
+[odoo-master.hr_holidays]
file_filter = addons/hr_holidays/i18n/<lang>.po
source_file = addons/hr_holidays/i18n/hr_holidays.pot
source_lang = en
-[odoo-12.hr_maintenance]
+[odoo-master.hr_maintenance]
file_filter = addons/hr_maintenance/i18n/<lang>.po
source_file = addons/hr_maintenance/i18n/hr_maintenance.pot
source_lang = en
-[odoo-12.hr_org_chart]
+[odoo-master.hr_org_chart]
file_filter = addons/hr_org_chart/i18n/<lang>.po
source_file = addons/hr_org_chart/i18n/hr_org_chart.pot
source_lang = en
-[odoo-12.hr_payroll]
+[odoo-master.hr_payroll]
file_filter = addons/hr_payroll/i18n/<lang>.po
source_file = addons/hr_payroll/i18n/hr_payroll.pot
source_lang = en
-[odoo-12.hr_payroll_account]
+[odoo-master.hr_payroll_account]
file_filter = addons/hr_payroll_account/i18n/<lang>.po
source_file = addons/hr_payroll_account/i18n/hr_payroll_account.pot
source_lang = en
-[odoo-12.hr_recruitment]
+[odoo-master.hr_recruitment]
file_filter = addons/hr_recruitment/i18n/<lang>.po
source_file = addons/hr_recruitment/i18n/hr_recruitment.pot
source_lang = en
-[odoo-12.hr_recruitment_survey]
+[odoo-master.hr_recruitment_survey]
file_filter = addons/hr_recruitment_survey/i18n/<lang>.po
source_file = addons/hr_recruitment_survey/i18n/hr_recruitment_survey.pot
source_lang = en
-[odoo-12.hr_timesheet]
+[odoo-master.hr_timesheet]
file_filter = addons/hr_timesheet/i18n/<lang>.po
source_file = addons/hr_timesheet/i18n/hr_timesheet.pot
source_lang = en
-[odoo-12.hr_timesheet_attendance]
+[odoo-master.hr_timesheet_attendance]
file_filter = addons/hr_timesheet_attendance/i18n/<lang>.po
source_file = addons/hr_timesheet_attendance/i18n/hr_timesheet_attendance.pot
source_lang = en
-[odoo-12.http_routing]
+[odoo-master.http_routing]
file_filter = addons/http_routing/i18n/<lang>.po
source_file = addons/http_routing/i18n/http_routing.pot
source_lang = en
-[odoo-12.iap]
+[odoo-master.iap]
file_filter = addons/iap/i18n/<lang>.po
source_file = addons/iap/i18n/iap.pot
source_lang = en
-[odoo-12.im_livechat]
+[odoo-master.im_livechat]
file_filter = addons/im_livechat/i18n/<lang>.po
source_file = addons/im_livechat/i18n/im_livechat.pot
source_lang = en
-[odoo-12.im_livechat_mail_bot]
+[odoo-master.im_livechat_mail_bot]
file_filter = addons/im_livechat_mail_bot/i18n/<lang>.po
source_file = addons/im_livechat_mail_bot/i18n/im_livechat_mail_bot.pot
source_lang = en
-[odoo-12.im_support]
+[odoo-master.im_support]
file_filter = addons/im_support/i18n/<lang>.po
source_file = addons/im_support/i18n/im_support.pot
source_lang = en
-[odoo-12.l10n_multilang]
+[odoo-master.l10n_multilang]
file_filter = addons/l10n_multilang/i18n/<lang>.po
source_file = addons/l10n_multilang/i18n/l10n_multilang.pot
source_lang = en
-[odoo-12.link_tracker]
+[odoo-master.link_tracker]
file_filter = addons/link_tracker/i18n/<lang>.po
source_file = addons/link_tracker/i18n/link_tracker.pot
source_lang = en
-[odoo-12.lunch]
+[odoo-master.lunch]
file_filter = addons/lunch/i18n/<lang>.po
source_file = addons/lunch/i18n/lunch.pot
source_lang = en
-[odoo-12.mail]
+[odoo-master.mail]
file_filter = addons/mail/i18n/<lang>.po
source_file = addons/mail/i18n/mail.pot
source_lang = en
-[odoo-12.mail_bot]
+[odoo-master.mail_bot]
file_filter = addons/mail_bot/i18n/<lang>.po
source_file = addons/mail_bot/i18n/mail_bot.pot
source_lang = en
-[odoo-12.maintenance]
+[odoo-master.maintenance]
file_filter = addons/maintenance/i18n/<lang>.po
source_file = addons/maintenance/i18n/maintenance.pot
source_lang = en
-[odoo-12.mass_mailing]
+[odoo-master.mass_mailing]
file_filter = addons/mass_mailing/i18n/<lang>.po
source_file = addons/mass_mailing/i18n/mass_mailing.pot
source_lang = en
-[odoo-12.mass_mailing_crm]
+[odoo-master.mass_mailing_crm]
file_filter = addons/mass_mailing_crm/i18n/<lang>.po
source_file = addons/mass_mailing_crm/i18n/mass_mailing_crm.pot
source_lang = en
-[odoo-12.mass_mailing_event]
+[odoo-master.mass_mailing_event]
file_filter = addons/mass_mailing_event/i18n/<lang>.po
source_file = addons/mass_mailing_event/i18n/mass_mailing_event.pot
source_lang = en
-[odoo-12.mass_mailing_event_track]
+[odoo-master.mass_mailing_event_track]
file_filter = addons/mass_mailing_event_track/i18n/<lang>.po
source_file = addons/mass_mailing_event_track/i18n/mass_mailing_event_track.pot
source_lang = en
-[odoo-12.mass_mailing_sale]
+[odoo-master.mass_mailing_sale]
file_filter = addons/mass_mailing_sale/i18n/<lang>.po
source_file = addons/mass_mailing_sale/i18n/mass_mailing_sale.pot
source_lang = en
-[odoo-12.membership]
+[odoo-master.membership]
file_filter = addons/membership/i18n/<lang>.po
source_file = addons/membership/i18n/membership.pot
source_lang = en
-[odoo-12.mrp]
+[odoo-master.mrp]
file_filter = addons/mrp/i18n/<lang>.po
source_file = addons/mrp/i18n/mrp.pot
source_lang = en
-[odoo-12.mrp_bom_cost]
+[odoo-master.mrp_bom_cost]
file_filter = addons/mrp_bom_cost/i18n/<lang>.po
source_file = addons/mrp_bom_cost/i18n/mrp_bom_cost.pot
source_lang = en
-[odoo-12.mrp_byproduct]
+[odoo-master.mrp_byproduct]
file_filter = addons/mrp_byproduct/i18n/<lang>.po
source_file = addons/mrp_byproduct/i18n/mrp_byproduct.pot
source_lang = en
-[odoo-12.note]
+[odoo-master.note]
file_filter = addons/note/i18n/<lang>.po
source_file = addons/note/i18n/note.pot
source_lang = en
-[odoo-12.note_pad]
+[odoo-master.note_pad]
file_filter = addons/note_pad/i18n/<lang>.po
source_file = addons/note_pad/i18n/note_pad.pot
source_lang = en
-[odoo-12.pad]
+[odoo-master.pad]
file_filter = addons/pad/i18n/<lang>.po
source_file = addons/pad/i18n/pad.pot
source_lang = en
-[odoo-12.pad_project]
+[odoo-master.pad_project]
file_filter = addons/pad_project/i18n/<lang>.po
source_file = addons/pad_project/i18n/pad_project.pot
source_lang = en
-[odoo-12.partner_autocomplete]
+[odoo-master.partner_autocomplete]
file_filter = addons/partner_autocomplete/i18n/<lang>.po
source_file = addons/partner_autocomplete/i18n/partner_autocomplete.pot
source_lang = en
-[odoo-12.payment]
+[odoo-master.payment]
file_filter = addons/payment/i18n/<lang>.po
source_file = addons/payment/i18n/payment.pot
source_lang = en
-[odoo-12.payment_adyen]
+[odoo-master.payment_adyen]
file_filter = addons/payment_adyen/i18n/<lang>.po
source_file = addons/payment_adyen/i18n/payment_adyen.pot
source_lang = en
-[odoo-12.payment_authorize]
+[odoo-master.payment_authorize]
file_filter = addons/payment_authorize/i18n/<lang>.po
source_file = addons/payment_authorize/i18n/payment_authorize.pot
source_lang = en
-[odoo-12.payment_buckaroo]
+[odoo-master.payment_buckaroo]
file_filter = addons/payment_buckaroo/i18n/<lang>.po
source_file = addons/payment_buckaroo/i18n/payment_buckaroo.pot
source_lang = en
-[odoo-12.payment_ogone]
+[odoo-master.payment_ogone]
file_filter = addons/payment_ogone/i18n/<lang>.po
source_file = addons/payment_ogone/i18n/payment_ogone.pot
source_lang = en
-[odoo-12.payment_paypal]
+[odoo-master.payment_paypal]
file_filter = addons/payment_paypal/i18n/<lang>.po
source_file = addons/payment_paypal/i18n/payment_paypal.pot
source_lang = en
-[odoo-12.payment_payumoney]
+[odoo-master.payment_payumoney]
file_filter = addons/payment_payumoney/i18n/<lang>.po
source_file = addons/payment_payumoney/i18n/payment_payumoney.pot
source_lang = en
-[odoo-12.payment_sips]
+[odoo-master.payment_sips]
file_filter = addons/payment_sips/i18n/<lang>.po
source_file = addons/payment_sips/i18n/payment_sips.pot
source_lang = en
-[odoo-12.payment_stripe]
+[odoo-master.payment_stripe]
file_filter = addons/payment_stripe/i18n/<lang>.po
source_file = addons/payment_stripe/i18n/payment_stripe.pot
source_lang = en
-[odoo-12.payment_transfer]
+[odoo-master.payment_transfer]
file_filter = addons/payment_transfer/i18n/<lang>.po
source_file = addons/payment_transfer/i18n/payment_transfer.pot
source_lang = en
-[odoo-12.phone_validation]
+[odoo-master.phone_validation]
file_filter = addons/phone_validation/i18n/<lang>.po
source_file = addons/phone_validation/i18n/phone_validation.pot
source_lang = en
-[odoo-12.point_of_sale]
+[odoo-master.point_of_sale]
file_filter = addons/point_of_sale/i18n/<lang>.po
source_file = addons/point_of_sale/i18n/point_of_sale.pot
source_lang = en
-[odoo-12.portal]
+[odoo-master.portal]
file_filter = addons/portal/i18n/<lang>.po
source_file = addons/portal/i18n/portal.pot
source_lang = en
-[odoo-12.pos_cache]
+[odoo-master.pos_cache]
file_filter = addons/pos_cache/i18n/<lang>.po
source_file = addons/pos_cache/i18n/pos_cache.pot
source_lang = en
-[odoo-12.pos_discount]
+[odoo-master.pos_discount]
file_filter = addons/pos_discount/i18n/<lang>.po
source_file = addons/pos_discount/i18n/pos_discount.pot
source_lang = en
-[odoo-12.pos_mercury]
+[odoo-master.pos_mercury]
file_filter = addons/pos_mercury/i18n/<lang>.po
source_file = addons/pos_mercury/i18n/pos_mercury.pot
source_lang = en
-[odoo-12.pos_reprint]
+[odoo-master.pos_reprint]
file_filter = addons/pos_reprint/i18n/<lang>.po
source_file = addons/pos_reprint/i18n/pos_reprint.pot
source_lang = en
-[odoo-12.pos_restaurant]
+[odoo-master.pos_restaurant]
file_filter = addons/pos_restaurant/i18n/<lang>.po
source_file = addons/pos_restaurant/i18n/pos_restaurant.pot
source_lang = en
-[odoo-12.pos_sale]
+[odoo-master.pos_sale]
file_filter = addons/pos_sale/i18n/<lang>.po
source_file = addons/pos_sale/i18n/pos_sale.pot
source_lang = en
-[odoo-12.procurement_jit]
+[odoo-master.procurement_jit]
file_filter = addons/procurement_jit/i18n/<lang>.po
source_file = addons/procurement_jit/i18n/procurement_jit.pot
source_lang = en
-[odoo-12.product]
+[odoo-master.product]
file_filter = addons/product/i18n/<lang>.po
source_file = addons/product/i18n/product.pot
source_lang = en
-[odoo-12.product_email_template]
+[odoo-master.product_email_template]
file_filter = addons/product_email_template/i18n/<lang>.po
source_file = addons/product_email_template/i18n/product_email_template.pot
source_lang = en
-[odoo-12.product_expiry]
+[odoo-master.product_expiry]
file_filter = addons/product_expiry/i18n/<lang>.po
source_file = addons/product_expiry/i18n/product_expiry.pot
source_lang = en
-[odoo-12.product_margin]
+[odoo-master.product_margin]
file_filter = addons/product_margin/i18n/<lang>.po
source_file = addons/product_margin/i18n/product_margin.pot
source_lang = en
-[odoo-12.project]
+[odoo-master.project]
file_filter = addons/project/i18n/<lang>.po
source_file = addons/project/i18n/project.pot
source_lang = en
-[odoo-12.project_timesheet_holidays]
+[odoo-master.project_timesheet_holidays]
file_filter = addons/project_timesheet_holidays/i18n/<lang>.po
source_file = addons/project_timesheet_holidays/i18n/project_timesheet_holidays.pot
source_lang = en
-[odoo-12.purchase]
+[odoo-master.purchase]
file_filter = addons/purchase/i18n/<lang>.po
source_file = addons/purchase/i18n/purchase.pot
source_lang = en
-[odoo-12.purchase_mrp]
+[odoo-master.purchase_mrp]
file_filter = addons/purchase_mrp/i18n/<lang>.po
source_file = addons/purchase_mrp/i18n/purchase_mrp.pot
source_lang = en
-[odoo-12.purchase_requisition]
+[odoo-master.purchase_requisition]
file_filter = addons/purchase_requisition/i18n/<lang>.po
source_file = addons/purchase_requisition/i18n/purchase_requisition.pot
source_lang = en
-[odoo-12.purchase_stock]
+[odoo-master.purchase_stock]
file_filter = addons/purchase_stock/i18n/<lang>.po
source_file = addons/purchase_stock/i18n/purchase_stock.pot
source_lang = en
-[odoo-12.rating]
+[odoo-master.rating]
file_filter = addons/rating/i18n/<lang>.po
source_file = addons/rating/i18n/rating.pot
source_lang = en
-[odoo-12.repair]
+[odoo-master.repair]
file_filter = addons/repair/i18n/<lang>.po
source_file = addons/repair/i18n/repair.pot
source_lang = en
-[odoo-12.resource]
+[odoo-master.resource]
file_filter = addons/resource/i18n/<lang>.po
source_file = addons/resource/i18n/resource.pot
source_lang = en
-[odoo-12.sale]
+[odoo-master.sale]
file_filter = addons/sale/i18n/<lang>.po
source_file = addons/sale/i18n/sale.pot
source_lang = en
-[odoo-12.sale_crm]
+[odoo-master.sale_crm]
file_filter = addons/sale_crm/i18n/<lang>.po
source_file = addons/sale_crm/i18n/sale_crm.pot
source_lang = en
-[odoo-12.sale_expense]
+[odoo-master.sale_expense]
file_filter = addons/sale_expense/i18n/<lang>.po
source_file = addons/sale_expense/i18n/sale_expense.pot
source_lang = en
-[odoo-12.sale_management]
+[odoo-master.sale_management]
file_filter = addons/sale_management/i18n/<lang>.po
source_file = addons/sale_management/i18n/sale_management.pot
source_lang = en
-[odoo-12.sale_margin]
+[odoo-master.sale_margin]
file_filter = addons/sale_margin/i18n/<lang>.po
source_file = addons/sale_margin/i18n/sale_margin.pot
source_lang = en
-[odoo-12.sale_mrp]
+[odoo-master.sale_mrp]
file_filter = addons/sale_mrp/i18n/<lang>.po
source_file = addons/sale_mrp/i18n/sale_mrp.pot
source_lang = en
-[odoo-12.sale_purchase]
+[odoo-master.sale_purchase]
file_filter = addons/sale_purchase/i18n/<lang>.po
source_file = addons/sale_purchase/i18n/sale_purchase.pot
source_lang = en
-[odoo-12.sale_quotation_builder]
+[odoo-master.sale_quotation_builder]
file_filter = addons/sale_quotation_builder/i18n/<lang>.po
source_file = addons/sale_quotation_builder/i18n/sale_quotation_builder.pot
source_lang = en
-[odoo-12.sale_stock]
+[odoo-master.sale_stock]
file_filter = addons/sale_stock/i18n/<lang>.po
source_file = addons/sale_stock/i18n/sale_stock.pot
source_lang = en
-[odoo-12.sale_timesheet]
+[odoo-master.sale_timesheet]
file_filter = addons/sale_timesheet/i18n/<lang>.po
source_file = addons/sale_timesheet/i18n/sale_timesheet.pot
source_lang = en
-[odoo-12.sales_team]
+[odoo-master.sales_team]
file_filter = addons/sales_team/i18n/<lang>.po
source_file = addons/sales_team/i18n/sales_team.pot
source_lang = en
-[odoo-12.sms]
+[odoo-master.sms]
file_filter = addons/sms/i18n/<lang>.po
source_file = addons/sms/i18n/sms.pot
source_lang = en
-[odoo-12.snailmail]
+[odoo-master.snailmail]
file_filter = addons/snailmail/i18n/<lang>.po
source_file = addons/snailmail/i18n/snailmail.pot
source_lang = en
-[odoo-12.snailmail_account]
+[odoo-master.snailmail_account]
file_filter = addons/snailmail_account/i18n/<lang>.po
source_file = addons/snailmail_account/i18n/snailmail_account.pot
source_lang = en
-[odoo-12.social_media]
+[odoo-master.social_media]
file_filter = addons/social_media/i18n/<lang>.po
source_file = addons/social_media/i18n/social_media.pot
source_lang = en
-[odoo-12.stock]
+[odoo-master.stock]
file_filter = addons/stock/i18n/<lang>.po
source_file = addons/stock/i18n/stock.pot
source_lang = en
-[odoo-12.stock_account]
+[odoo-master.stock_account]
file_filter = addons/stock_account/i18n/<lang>.po
source_file = addons/stock_account/i18n/stock_account.pot
source_lang = en
-[odoo-12.stock_dropshipping]
+[odoo-master.stock_dropshipping]
file_filter = addons/stock_dropshipping/i18n/<lang>.po
source_file = addons/stock_dropshipping/i18n/stock_dropshipping.pot
source_lang = en
-[odoo-12.stock_landed_costs]
+[odoo-master.stock_landed_costs]
file_filter = addons/stock_landed_costs/i18n/<lang>.po
source_file = addons/stock_landed_costs/i18n/stock_landed_costs.pot
source_lang = en
-[odoo-12.stock_picking_batch]
+[odoo-master.stock_picking_batch]
file_filter = addons/stock_picking_batch/i18n/<lang>.po
source_file = addons/stock_picking_batch/i18n/stock_picking_batch.pot
source_lang = en
-[odoo-12.stock_zebra]
+[odoo-master.stock_zebra]
file_filter = addons/stock_zebra/i18n/<lang>.po
source_file = addons/stock_zebra/i18n/stock_zebra.pot
source_lang = en
-[odoo-12.survey]
+[odoo-master.survey]
file_filter = addons/survey/i18n/<lang>.po
source_file = addons/survey/i18n/survey.pot
source_lang = en
-[odoo-12.survey_crm]
+[odoo-master.survey_crm]
file_filter = addons/survey_crm/i18n/<lang>.po
source_file = addons/survey_crm/i18n/survey_crm.pot
source_lang = en
-[odoo-12.theme_bootswatch]
+[odoo-master.theme_bootswatch]
file_filter = addons/theme_bootswatch/i18n/<lang>.po
source_file = addons/theme_bootswatch/i18n/theme_bootswatch.pot
source_lang = en
-[odoo-12.transifex]
+[odoo-master.transifex]
file_filter = addons/transifex/i18n/<lang>.po
source_file = addons/transifex/i18n/transifex.pot
source_lang = en
-[odoo-12.uom]
+[odoo-master.uom]
file_filter = addons/uom/i18n/<lang>.po
source_file = addons/uom/i18n/uom.pot
source_lang = en
-[odoo-12.utm]
+[odoo-master.utm]
file_filter = addons/utm/i18n/<lang>.po
source_file = addons/utm/i18n/utm.pot
source_lang = en
-[odoo-12.web]
+[odoo-master.web]
file_filter = addons/web/i18n/<lang>.po
source_file = addons/web/i18n/web.pot
source_lang = en
-[odoo-12.web_diagram]
+[odoo-master.web_diagram]
file_filter = addons/web_diagram/i18n/<lang>.po
source_file = addons/web_diagram/i18n/web_diagram.pot
source_lang = en
-[odoo-12.web_editor]
+[odoo-master.web_editor]
file_filter = addons/web_editor/i18n/<lang>.po
source_file = addons/web_editor/i18n/web_editor.pot
source_lang = en
-[odoo-12.web_kanban_gauge]
+[odoo-master.web_kanban_gauge]
file_filter = addons/web_kanban_gauge/i18n/<lang>.po
source_file = addons/web_kanban_gauge/i18n/web_kanban_gauge.pot
source_lang = en
-[odoo-12.web_settings_dashboard]
+[odoo-master.web_settings_dashboard]
file_filter = addons/web_settings_dashboard/i18n/<lang>.po
source_file = addons/web_settings_dashboard/i18n/web_settings_dashboard.pot
source_lang = en
-[odoo-12.web_tour]
+[odoo-master.web_tour]
file_filter = addons/web_tour/i18n/<lang>.po
source_file = addons/web_tour/i18n/web_tour.pot
source_lang = en
-[odoo-12.web_unsplash]
+[odoo-master.web_unsplash]
file_filter = addons/web_unsplash/i18n/<lang>.po
source_file = addons/web_unsplash/i18n/web_unsplash.pot
source_lang = en
-[odoo-12.website]
+[odoo-master.website]
file_filter = addons/website/i18n/<lang>.po
source_file = addons/website/i18n/website.pot
source_lang = en
-[odoo-12.website_blog]
+[odoo-master.website_blog]
file_filter = addons/website_blog/i18n/<lang>.po
source_file = addons/website_blog/i18n/website_blog.pot
source_lang = en
-[odoo-12.website_crm]
+[odoo-master.website_crm]
file_filter = addons/website_crm/i18n/<lang>.po
source_file = addons/website_crm/i18n/website_crm.pot
source_lang = en
-[odoo-12.website_crm_partner_assign]
+[odoo-master.website_crm_partner_assign]
file_filter = addons/website_crm_partner_assign/i18n/<lang>.po
source_file = addons/website_crm_partner_assign/i18n/website_crm_partner_assign.pot
source_lang = en
-[odoo-12.website_customer]
+[odoo-master.website_customer]
file_filter = addons/website_customer/i18n/<lang>.po
source_file = addons/website_customer/i18n/website_customer.pot
source_lang = en
-[odoo-12.website_event]
+[odoo-master.website_event]
file_filter = addons/website_event/i18n/<lang>.po
source_file = addons/website_event/i18n/website_event.pot
source_lang = en
-[odoo-12.website_event_questions]
+[odoo-master.website_event_questions]
file_filter = addons/website_event_questions/i18n/<lang>.po
source_file = addons/website_event_questions/i18n/website_event_questions.pot
source_lang = en
-[odoo-12.website_event_sale]
+[odoo-master.website_event_sale]
file_filter = addons/website_event_sale/i18n/<lang>.po
source_file = addons/website_event_sale/i18n/website_event_sale.pot
source_lang = en
-[odoo-12.website_event_track]
+[odoo-master.website_event_track]
file_filter = addons/website_event_track/i18n/<lang>.po
source_file = addons/website_event_track/i18n/website_event_track.pot
source_lang = en
-[odoo-12.website_form]
+[odoo-master.website_form]
file_filter = addons/website_form/i18n/<lang>.po
source_file = addons/website_form/i18n/website_form.pot
source_lang = en
-[odoo-12.website_forum]
+[odoo-master.website_forum]
file_filter = addons/website_forum/i18n/<lang>.po
source_file = addons/website_forum/i18n/website_forum.pot
source_lang = en
-[odoo-12.website_gengo]
+[odoo-master.website_gengo]
file_filter = addons/website_gengo/i18n/<lang>.po
source_file = addons/website_gengo/i18n/website_gengo.pot
source_lang = en
-[odoo-12.website_hr]
+[odoo-master.website_hr]
file_filter = addons/website_hr/i18n/<lang>.po
source_file = addons/website_hr/i18n/website_hr.pot
source_lang = en
-[odoo-12.website_hr_recruitment]
+[odoo-master.website_hr_recruitment]
file_filter = addons/website_hr_recruitment/i18n/<lang>.po
source_file = addons/website_hr_recruitment/i18n/website_hr_recruitment.pot
source_lang = en
-[odoo-12.website_links]
+[odoo-master.website_links]
file_filter = addons/website_links/i18n/<lang>.po
source_file = addons/website_links/i18n/website_links.pot
source_lang = en
-[odoo-12.website_livechat]
+[odoo-master.website_livechat]
file_filter = addons/website_livechat/i18n/<lang>.po
source_file = addons/website_livechat/i18n/website_livechat.pot
source_lang = en
-[odoo-12.website_mail]
+[odoo-master.website_mail]
file_filter = addons/website_mail/i18n/<lang>.po
source_file = addons/website_mail/i18n/website_mail.pot
source_lang = en
-[odoo-12.website_mail_channel]
+[odoo-master.website_mail_channel]
file_filter = addons/website_mail_channel/i18n/<lang>.po
source_file = addons/website_mail_channel/i18n/website_mail_channel.pot
source_lang = en
-[odoo-12.website_mass_mailing]
+[odoo-master.website_mass_mailing]
file_filter = addons/website_mass_mailing/i18n/<lang>.po
source_file = addons/website_mass_mailing/i18n/website_mass_mailing.pot
source_lang = en
-[odoo-12.website_membership]
+[odoo-master.website_membership]
file_filter = addons/website_membership/i18n/<lang>.po
source_file = addons/website_membership/i18n/website_membership.pot
source_lang = en
-[odoo-12.website_partner]
+[odoo-master.website_partner]
file_filter = addons/website_partner/i18n/<lang>.po
source_file = addons/website_partner/i18n/website_partner.pot
source_lang = en
-[odoo-12.website_payment]
+[odoo-master.website_payment]
file_filter = addons/website_payment/i18n/<lang>.po
source_file = addons/website_payment/i18n/website_payment.pot
source_lang = en
-[odoo-12.website_rating]
+[odoo-master.website_rating]
file_filter = addons/website_rating/i18n/<lang>.po
source_file = addons/website_rating/i18n/website_rating.pot
source_lang = en
-[odoo-12.website_sale]
+[odoo-master.website_sale]
file_filter = addons/website_sale/i18n/<lang>.po
source_file = addons/website_sale/i18n/website_sale.pot
source_lang = en
-[odoo-12.website_sale_comparison]
+[odoo-master.website_sale_comparison]
file_filter = addons/website_sale_comparison/i18n/<lang>.po
source_file = addons/website_sale_comparison/i18n/website_sale_comparison.pot
source_lang = en
-[odoo-12.website_sale_delivery]
+[odoo-master.website_sale_delivery]
file_filter = addons/website_sale_delivery/i18n/<lang>.po
source_file = addons/website_sale_delivery/i18n/website_sale_delivery.pot
source_lang = en
-[odoo-12.website_sale_digital]
+[odoo-master.website_sale_digital]
file_filter = addons/website_sale_digital/i18n/<lang>.po
source_file = addons/website_sale_digital/i18n/website_sale_digital.pot
source_lang = en
-[odoo-12.website_sale_link_tracker]
+[odoo-master.website_sale_link_tracker]
file_filter = addons/website_sale_link_tracker/i18n/<lang>.po
source_file = addons/website_sale_link_tracker/i18n/website_sale_link_tracker.pot
source_lang = en
-[odoo-12.website_sale_management]
+[odoo-master.website_sale_management]
file_filter = addons/website_sale_management/i18n/<lang>.po
source_file = addons/website_sale_management/i18n/website_sale_management.pot
source_lang = en
-[odoo-12.website_sale_stock]
+[odoo-master.website_sale_stock]
file_filter = addons/website_sale_stock/i18n/<lang>.po
source_file = addons/website_sale_stock/i18n/website_sale_stock.pot
source_lang = en
-[odoo-12.website_sale_wishlist]
+[odoo-master.website_sale_wishlist]
file_filter = addons/website_sale_wishlist/i18n/<lang>.po
source_file = addons/website_sale_wishlist/i18n/website_sale_wishlist.pot
source_lang = en
-[odoo-12.website_slides]
+[odoo-master.website_slides]
file_filter = addons/website_slides/i18n/<lang>.po
source_file = addons/website_slides/i18n/website_slides.pot
source_lang = en
-[odoo-12.website_theme_install]
+[odoo-master.website_theme_install]
file_filter = addons/website_theme_install/i18n/<lang>.po
source_file = addons/website_theme_install/i18n/website_theme_install.pot
source_lang = en
-[odoo-12.website_twitter]
+[odoo-master.website_twitter]
file_filter = addons/website_twitter/i18n/<lang>.po
source_file = addons/website_twitter/i18n/website_twitter.pot
source_lang = en
diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py
index ab46cde5838..c0eb0808dc4 100644
--- a/addons/account/models/account_invoice.py
+++ b/addons/account/models/account_invoice.py
@@ -1500,11 +1500,11 @@ class AccountInvoice(models.Model):
def _track_subtype(self, init_values):
self.ensure_one()
if 'state' in init_values and self.state == 'paid' and self.type in ('out_invoice', 'out_refund'):
- return 'account.mt_invoice_paid'
+ return self.env.ref('account.mt_invoice_paid')
elif 'state' in init_values and self.state == 'open' and self.type in ('out_invoice', 'out_refund'):
- return 'account.mt_invoice_validated'
+ return self.env.ref('account.mt_invoice_validated')
elif 'state' in init_values and self.state == 'draft' and self.type in ('out_invoice', 'out_refund'):
- return 'account.mt_invoice_created'
+ return self.env.ref('account.mt_invoice_created')
return super(AccountInvoice, self)._track_subtype(init_values)
def _amount_by_group(self):
diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py
index 3c1c7917687..9e94cc389e8 100644
--- a/addons/account/models/account_move.py
+++ b/addons/account/models/account_move.py
@@ -618,7 +618,7 @@ class AccountMoveLine(models.Model):
statement_id = fields.Many2one('account.bank.statement', related='statement_line_id.statement_id', string='Statement', store=True,
help="The bank statement used for bank reconciliation", index=True, copy=False)
reconciled = fields.Boolean(compute='_amount_residual', store=True)
- full_reconcile_id = fields.Many2one('account.full.reconcile', string="Matching Number", copy=False)
+ full_reconcile_id = fields.Many2one('account.full.reconcile', string="Matching Number", copy=False, index=True)
matched_debit_ids = fields.One2many('account.partial.reconcile', 'credit_move_id', String='Matched Debits',
help='Debit journal items that are matched with this journal item.')
matched_credit_ids = fields.One2many('account.partial.reconcile', 'debit_move_id', String='Matched Credits',
diff --git a/addons/account/static/src/scss/variables.scss b/addons/account/static/src/scss/variables.scss
index b83890b01a1..72a0a315181 100644
--- a/addons/account/static/src/scss/variables.scss
+++ b/addons/account/static/src/scss/variables.scss
@@ -1,5 +1,5 @@
-$o-account-action-col-width: 15px !default;
-$o-account-main-table-borders-padding: 3px !default;
-$o-account-light-border: 1px solid #bbb !default;
-$o-account-initial-line-background: #f0f0f0 !default;
-$o-account-info-color: #44c !default;
+$o-account-action-col-width: 15px;
+$o-account-main-table-borders-padding: 3px;
+$o-account-light-border: 1px solid #bbb;
+$o-account-initial-line-background: #f0f0f0;
+$o-account-info-color: #44c;
diff --git a/addons/account/tests/test_fiscal_position.py b/addons/account/tests/test_fiscal_position.py
index 320c94cca80..ded27e530a9 100644
--- a/addons/account/tests/test_fiscal_position.py
+++ b/addons/account/tests/test_fiscal_position.py
@@ -125,3 +125,27 @@ class TestFiscalPosition(common.TransactionCase):
# Dedicated position has max precedence
george.property_account_position_id = self.be_nat
assert_fp(george, self.be_nat, "Forced position has max precedence")
+
+
+ def test_20_fp_one_tax_2m(self):
+
+ self.src_tax = self.env['account.tax'].create({'name': "SRC", 'amount': 0.0})
+ self.dst1_tax = self.env['account.tax'].create({'name': "DST1", 'amount': 0.0})
+ self.dst2_tax = self.env['account.tax'].create({'name': "DST2", 'amount': 0.0})
+
+ self.fp2m = self.fp.create({
+ 'name': "FP-TAX2TAXES",
+ 'tax_ids': [
+ (0,0,{
+ 'tax_src_id': self.src_tax.id,
+ 'tax_dest_id': self.dst1_tax.id
+ }),
+ (0,0,{
+ 'tax_src_id': self.src_tax.id,
+ 'tax_dest_id': self.dst2_tax.id
+ })
+ ]
+ })
+ mapped_taxes = self.fp2m.map_tax(self.src_tax)
+
+ self.assertEqual(mapped_taxes, self.dst1_tax | self.dst2_tax)
diff --git a/addons/account/views/account.xml b/addons/account/views/account.xml
index bba2ec420a1..57539d4f6a1 100644
--- a/addons/account/views/account.xml
+++ b/addons/account/views/account.xml
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
- <template id="_assets_backend_helpers" inherit_id="web._assets_backend_helpers">
- <xpath expr="//link" position="before">
+ <template id="_assets_primary_variables" inherit_id="web._assets_primary_variables">
+ <xpath expr="//link[last()]" position="after">
<link rel="stylesheet" type="text/scss" href="/account/static/src/scss/variables.scss"/>
</xpath>
</template>
diff --git a/addons/account/views/account_view.xml b/addons/account/views/account_view.xml
index 1268c9d1232..3d15d0976c3 100644
--- a/addons/account/views/account_view.xml
+++ b/addons/account/views/account_view.xml
@@ -2316,11 +2316,13 @@
<field name="model">account.group</field>
<field name="arch" type="xml">
<form string="Account Group">
+ <sheet>
<group>
<field name="name"/>
<field name="code_prefix"/>
<field name="parent_id"/>
</group>
+ </sheet>
</form>
</field>
</record>
diff --git a/addons/account/wizard/setup_wizards.py b/addons/account/wizard/setup_wizards.py
index 096604b13d7..2aac7495fd5 100644
--- a/addons/account/wizard/setup_wizards.py
+++ b/addons/account/wizard/setup_wizards.py
@@ -1,7 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from odoo import api, fields, models
+from datetime import date
+
+from odoo import _, api, fields, models
+from odoo.exceptions import ValidationError
class FinancialYearOpeningWizard(models.TransientModel):
@@ -23,6 +26,19 @@ class FinancialYearOpeningWizard(models.TransientModel):
for record in self:
record.opening_move_posted = record.company_id.opening_move_posted()
+ @api.constrains('fiscalyear_last_day', 'fiscalyear_last_month')
+ def _check_fiscalyear(self):
+ # We try if the date exists in 2020, which is a leap year.
+ # We do not define the constrain on res.company, since the recomputation of the related
+ # fields is done one field at a time.
+ for wiz in self:
+ try:
+ date(2020, wiz.fiscalyear_last_month, wiz.fiscalyear_last_day)
+ except ValueError:
+ raise ValidationError(
+ _('Incorrect fiscal year date: day is out of range for month. Month: %s; Day: %s') %
+ (wiz.fiscalyear_last_month, wiz.fiscalyear_last_day)
+ )
@api.multi
def action_save_onboarding_fiscal_year(self):
self.env.user.company_id.set_onboarding_step_done('account_setup_fy_data_state')
diff --git a/addons/account_analytic_default/views/account_analytic_default_view.xml b/addons/account_analytic_default/views/account_analytic_default_view.xml
index 23add773de3..950f4edd8c3 100644
--- a/addons/account_analytic_default/views/account_analytic_default_view.xml
+++ b/addons/account_analytic_default/views/account_analytic_default_view.xml
@@ -23,6 +23,7 @@
<field name="model">account.analytic.default</field>
<field name="arch" type="xml">
<form string="Analytic Defaults">
+ <sheet>
<group col="4">
<field name="analytic_id" groups="analytic.group_analytic_accounting"/>
<field name="analytic_tag_ids" widget="many2many_tags" groups="analytic.group_analytic_tags"/>
@@ -35,6 +36,7 @@
<field name="date_start"/>
<field name="date_stop"/>
</group>
+ </sheet>
</form>
</field>
</record>
diff --git a/addons/account_analytic_default_hr_expense/__init__.py b/addons/account_analytic_default_hr_expense/__init__.py
new file mode 100644
index 00000000000..dc5e6b693d1
--- /dev/null
+++ b/addons/account_analytic_default_hr_expense/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import models
diff --git a/addons/account_analytic_default_hr_expense/__manifest__.py b/addons/account_analytic_default_hr_expense/__manifest__.py
new file mode 100644
index 00000000000..4657ac3806d
--- /dev/null
+++ b/addons/account_analytic_default_hr_expense/__manifest__.py
@@ -0,0 +1,16 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+{
+ 'name': 'Account Analytic Defaults for expenses.',
+ 'version': '1.0',
+ 'category': 'Accounting',
+ 'description': """
+Set default values for your analytic accounts on your hr expenses.
+==================================================================
+
+Allows to automatically select analytic accounts based on Product
+ """,
+ 'depends': ['account_analytic_default', 'hr_expense'],
+ 'auto_install': True,
+}
diff --git a/addons/account_analytic_default_hr_expense/models/__init__.py b/addons/account_analytic_default_hr_expense/models/__init__.py
new file mode 100644
index 00000000000..c468c6330d6
--- /dev/null
+++ b/addons/account_analytic_default_hr_expense/models/__init__.py
@@ -0,0 +1,6 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models, _
+
+from . import hr_expense
diff --git a/addons/account_analytic_default_hr_expense/models/hr_expense.py b/addons/account_analytic_default_hr_expense/models/hr_expense.py
new file mode 100644
index 00000000000..fb782e5753c
--- /dev/null
+++ b/addons/account_analytic_default_hr_expense/models/hr_expense.py
@@ -0,0 +1,16 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models, _
+
+
+class HrExpense(models.Model):
+ _inherit = 'hr.expense'
+
+ @api.onchange('product_id', 'date')
+ def _onchange_product_id(self):
+ res = super(HrExpense, self)._onchange_product_id()
+ rec = self.env['account.analytic.default'].account_get(self.product_id.id, company_id=self.company_id.id, date=self.date)
+ self.analytic_account_id = rec.analytic_id.id
+ self.analytic_tag_ids = rec.analytic_tag_ids.ids
+ return res
diff --git a/addons/account_voucher/models/account_voucher.py b/addons/account_voucher/models/account_voucher.py
index c979480bf35..2c5d9d9f30c 100644
--- a/addons/account_voucher/models/account_voucher.py
+++ b/addons/account_voucher/models/account_voucher.py
@@ -390,7 +390,7 @@ class AccountVoucher(models.Model):
@api.multi
def _track_subtype(self, init_values):
if 'state' in init_values:
- return 'account_voucher.mt_voucher_state_change'
+ return self.env.ref('account_voucher.mt_voucher_state_change')
return super(AccountVoucher, self)._track_subtype(init_values)
diff --git a/addons/board/static/src/js/board_view.js b/addons/board/static/src/js/board_view.js
index 946cdff859f..3f9647de510 100644
--- a/addons/board/static/src/js/board_view.js
+++ b/addons/board/static/src/js/board_view.js
@@ -252,7 +252,7 @@ var BoardRenderer = FormRenderer.extend({
delete evalContext.group_by;
}
// tz and lang are saved in the custom view
- // override the https://github.com/odoo/odoo/pull/28408language to take the current one
+ // override the language to take the current one
var rawContext = new Context(action.context, evalContext, {lang: session.user_context.lang});
var context = pyUtils.eval('context', rawContext, evalContext);
var domain = params.domain || pyUtils.eval('domain', action.domain || '[]', action.context);
diff --git a/addons/calendar/data/calendar_data.xml b/addons/calendar/data/calendar_data.xml
index 29083593e32..a6bdafed7a6 100644
--- a/addons/calendar/data/calendar_data.xml
+++ b/addons/calendar/data/calendar_data.xml
@@ -11,43 +11,43 @@
<field name="name">15 Minute(s)</field>
<field name="duration" eval="15" />
<field name="interval">minutes</field>
- <field name="type">notification</field>
+ <field name="alarm_type">notification</field>
</record>
<record id="alarm_notif_2" model="calendar.alarm">
<field name="name">30 Minute(s)</field>
<field name="duration" eval="30" />
<field name="interval">minutes</field>
- <field name="type">notification</field>
+ <field name="alarm_type">notification</field>
</record>
<record id="alarm_notif_3" model="calendar.alarm">
<field name="name">1 Hour(s)</field>
<field name="duration" eval="1" />
<field name="interval">hours</field>
- <field name="type">notification</field>
+ <field name="alarm_type">notification</field>
</record>
<record id="alarm_notif_4" model="calendar.alarm">
<field name="name">2 Hour(s)</field>
<field name="duration" eval="2" />
<field name="interval">hours</field>
- <field name="type">notification</field>
+ <field name="alarm_type">notification</field>
</record>
<record id="alarm_notif_5" model="calendar.alarm">
<field name="name">1 Day(s)</field>
<field name="duration" eval="1" />
<field name="interval">days</field>
- <field name="type">notification</field>
+ <field name="alarm_type">notification</field>
</record>
<record id="alarm_mail_1" model="calendar.alarm">
<field name="name">3 Hour(s), by e-mail</field>
<field name="duration" eval="3" />
<field name="interval">hours</field>
- <field name="type">email</field>
+ <field name="alarm_type">email</field>
</record>
<record id="alarm_mail_2" model="calendar.alarm">
<field name="name">6 Hour(s), by e-mail</field>
<field name="duration" eval="6" />
<field name="interval">hours</field>
- <field name="type">email</field>
+ <field name="alarm_type">email</field>
</record>
</data>
</odoo> \ No newline at end of file
diff --git a/addons/calendar/models/calendar.py b/addons/calendar/models/calendar.py
index cc91739fb6a..c0e4c454090 100644
--- a/addons/calendar/models/calendar.py
+++ b/addons/calendar/models/calendar.py
@@ -251,7 +251,7 @@ class AlarmManager(models.AbstractModel):
FROM
calendar_alarm_calendar_event_rel AS rel
LEFT JOIN calendar_alarm AS alarm ON alarm.id = rel.calendar_alarm_id
- WHERE alarm.type = %s
+ WHERE alarm.alarm_type = %s
GROUP BY rel.calendar_event_id
"""
base_request = """
@@ -340,7 +340,7 @@ class AlarmManager(models.AbstractModel):
# TODO: remove event_maxdelta and if using it
if one_date - timedelta(minutes=(missing and 0 or event_maxdelta)) < datetime.datetime.now() + timedelta(seconds=in_the_next_X_seconds): # if an alarm is possible for this date
for alarm in event.alarm_ids:
- if alarm.type == alarm_type and \
+ if alarm.alarm_type == alarm_type and \
one_date - timedelta(minutes=(missing and 0 or alarm.duration_minutes)) < datetime.datetime.now() + timedelta(seconds=in_the_next_X_seconds) and \
(not after or one_date - timedelta(minutes=alarm.duration_minutes) > fields.Datetime.from_string(after)):
alert = {
@@ -438,7 +438,7 @@ class AlarmManager(models.AbstractModel):
alarm = self.env['calendar.alarm'].browse(alert['alarm_id'])
result = False
- if alarm.type == 'email':
+ if alarm.alarm_type == 'email':
result = meeting.attendee_ids.filtered(lambda r: r.state != 'declined')._send_mail_to_attendees('calendar.calendar_template_meeting_reminder', force_send=True)
return result
@@ -446,7 +446,7 @@ class AlarmManager(models.AbstractModel):
alarm = self.env['calendar.alarm'].browse(alert['alarm_id'])
meeting = self.env['calendar.event'].browse(alert['event_id'])
- if alarm.type == 'notification':
+ if alarm.alarm_type == 'notification':
message = meeting.display_time
delta = alert['notify_at'] - datetime.datetime.now()
@@ -490,7 +490,7 @@ class Alarm(models.Model):
_interval_selection = {'minutes': 'Minute(s)', 'hours': 'Hour(s)', 'days': 'Day(s)'}
name = fields.Char('Name', translate=True, required=True)
- type = fields.Selection([('notification', 'Notification'), ('email', 'Email')], 'Type', required=True, default='email')
+ alarm_type = fields.Selection([('notification', 'Notification'), ('email', 'Email')], string='Type', required=True, default='email', oldname='type')
duration = fields.Integer('Remind Before', required=True, default=1)
interval = fields.Selection(list(_interval_selection.items()), 'Unit', required=True, default='hours')
duration_minutes = fields.Integer('Duration in minutes', compute='_compute_duration_minutes', store=True, help="Duration in minutes")
@@ -505,7 +505,7 @@ class Alarm(models.Model):
cron = self.env['ir.model.data'].sudo().get_object('calendar', 'ir_cron_scheduler_alarm')
except ValueError:
return False
- return cron.toggle(model=self._name, domain=[('type', '=', 'email')])
+ return cron.toggle(model=self._name, domain=[('alarm_type', '=', 'email')])
@api.model
def create(self, values):
diff --git a/addons/calendar/tests/test_calendar.py b/addons/calendar/tests/test_calendar.py
index 9b8fd31897f..1d409d48dd7 100644
--- a/addons/calendar/tests/test_calendar.py
+++ b/addons/calendar/tests/test_calendar.py
@@ -94,7 +94,7 @@ class TestCalendar(TransactionCase):
'name': '1 Day before event starts',
'duration': 1,
'interval': 'days',
- 'type': 'notification'
+ 'alarm_type': 'notification'
})
# Now I will assign this reminder to all day event|
diff --git a/addons/calendar/views/calendar_views.xml b/addons/calendar/views/calendar_views.xml
index f8b5487c1e9..a59e24fb855 100644
--- a/addons/calendar/views/calendar_views.xml
+++ b/addons/calendar/views/calendar_views.xml
@@ -26,7 +26,7 @@
<field name="arch" type="xml">
<tree string="Calendar Alarm" editable="bottom">
<field name="name"/>
- <field name="type"/>
+ <field name="alarm_type"/>
<field name="duration"/>
<field name="interval"/>
</tree>
@@ -41,7 +41,7 @@
<group>
<group>
<field name="name"/>
- <field name="type" groups="base.group_no_one"/>
+ <field name="alarm_type" groups="base.group_no_one"/>
</group>
<group>
<label for="duration"/>
diff --git a/addons/calendar_sms/models/calendar.py b/addons/calendar_sms/models/calendar.py
index 986a46e5210..1f87174e150 100644
--- a/addons/calendar_sms/models/calendar.py
+++ b/addons/calendar_sms/models/calendar.py
@@ -28,7 +28,7 @@ class CalendarEvent(models.Model):
class CalendarAlarm(models.Model):
_inherit = 'calendar.alarm'
- type = fields.Selection(selection_add=[('sms', 'SMS Text Message')])
+ alarm_type = fields.Selection(selection_add=[('sms', 'SMS Text Message')])
class AlarmManager(models.AbstractModel):
diff --git a/addons/contacts/models/res_users.py b/addons/contacts/models/res_users.py
index 65062bcb6e7..04e16b713c6 100644
--- a/addons/contacts/models/res_users.py
+++ b/addons/contacts/models/res_users.py
@@ -17,9 +17,4 @@ class Users(models.Model):
if activity['model'] != 'res.partner':
continue
activity['icon'] = modules.module.get_module_icon('contacts')
- activity['actions'] = [{
- 'icon': 'fa-th',
- 'name': 'Summary',
- 'action_xmlid': 'contacts.action_contacts_summary'
- }]
return activities
diff --git a/addons/contacts/views/contact_views.xml b/addons/contacts/views/contact_views.xml
index efedaa7b393..077f17292f3 100644
--- a/addons/contacts/views/contact_views.xml
+++ b/addons/contacts/views/contact_views.xml
@@ -34,21 +34,6 @@
<field name="act_window_id" ref="action_contacts"/>
</record>
- <record id="action_contacts_summary" model="ir.actions.act_window">
- <field name="name">Contacts</field>
- <field name="type">ir.actions.act_window</field>
- <field name="res_model">res.partner</field>
- <field name="view_type">form</field>
- <field name="view_mode">activity,kanban,tree,form</field>
- <field name="search_view_id" ref="base.view_res_partner_filter"/>
- <field name="help" type="html">
- <p class="o_view_nocontent_smiling_face">
- Create a contact in your address book
- </p><p>
- Odoo helps you to easily track all activities related to a customer.
- </p>
- </field>
- </record>
<menuitem name="Contacts"
id="menu_contacts"
diff --git a/addons/crm/models/crm_lead.py b/addons/crm/models/crm_lead.py
index 679cf8a3247..f6dc16a9366 100644
--- a/addons/crm/models/crm_lead.py
+++ b/addons/crm/models/crm_lead.py
@@ -563,11 +563,11 @@ class Lead(models.Model):
value = next((v[1] for v in selections if v[0] == value), value)
elif field.ttype == 'many2one':
if value:
- value = value.sudo().name_get()[0][1]
+ value = value.sudo().display_name
elif field.ttype == 'many2many':
if value:
value = ','.join(
- val.name_get()[0][1]
+ val.display_name
for val in value.sudo()
)
body.append("%s: %s" % (field.field_description, value or ''))
@@ -1095,13 +1095,13 @@ class Lead(models.Model):
def _track_subtype(self, init_values):
self.ensure_one()
if 'stage_id' in init_values and self.probability == 100 and self.stage_id and self.stage_id.on_change:
- return 'crm.mt_lead_won'
+ return self.env.ref('crm.mt_lead_won')
elif 'active' in init_values and self.probability == 0 and not self.active:
- return 'crm.mt_lead_lost'
+ return self.env.ref('crm.mt_lead_lost')
elif 'stage_id' in init_values and self.stage_id and self.stage_id.sequence <= 1:
- return 'crm.mt_lead_create'
+ return self.env.ref('crm.mt_lead_create')
elif 'stage_id' in init_values:
- return 'crm.mt_lead_stage'
+ return self.env.ref('crm.mt_lead_stage')
return super(Lead, self)._track_subtype(init_values)
@api.multi
diff --git a/addons/crm/report/crm_opportunity_report_views.xml b/addons/crm/report/crm_opportunity_report_views.xml
index d263b5bc8b0..67a37ac25a2 100644
--- a/addons/crm/report/crm_opportunity_report_views.xml
+++ b/addons/crm/report/crm_opportunity_report_views.xml
@@ -7,7 +7,7 @@
<field name="model">crm.lead</field>
<field name="priority">60</field>
<field name="arch" type="xml">
- <pivot string="Pipeline Analysis" disable_linking="True">
+ <pivot string="Pipeline Analysis">
<field name="create_date" interval="month" type="col"/>
<field name="stage_id" type="row"/>
<field name="expected_revenue" type="measure"/>
@@ -20,7 +20,7 @@
<field name="model">crm.lead</field>
<field name="priority">60</field>
<field name="arch" type="xml">
- <pivot string="Leads Analysis" disable_linking="True">
+ <pivot string="Leads Analysis">
<field name="create_date" interval="month" type="row"/>
<field name="team_id" type="col"/>
</pivot>
@@ -115,15 +115,21 @@
<field name="view_ids"
eval="[(5, 0, 0),
(0, 0, {'view_mode': 'graph', 'view_id': ref('crm_opportunity_report_view_graph')}),
- (0, 0, {'view_mode': 'pivot', 'view_id': ref('crm_opportunity_report_view_pivot')})]"/>
+ (0, 0, {'view_mode': 'pivot', 'view_id': ref('crm_opportunity_report_view_pivot')}),
+ (0, 0, {'view_mode': 'tree', 'view_id': ref('crm_case_tree_view_oppor')}),
+ (0, 0, {'view_mode': 'form', 'view_id': ref('crm_case_form_view_oppor')})]"/>
<field name="help">Pipeline Analysis gives you an instant access to
-your opportunities with information such as the expected revenue, planned cost,
-missed deadlines or the number of interactions per opportunity. This report is
-mainly used by the sales manager in order to do the periodic review with the
-teams of the sales pipeline.</field>
+ your opportunities with information such as the expected revenue, planned cost,
+ missed deadlines or the number of interactions per opportunity. This report is
+ mainly used by the sales manager in order to do the periodic review with the
+ teams of the sales pipeline.</field>
</record>
- <menuitem id="crm_opportunity_report_menu" name="Pipeline"
- parent="crm_menu_report" action="crm_opportunity_report_action" sequence="2"/>
+
+ <menuitem id="crm_opportunity_report_menu"
+ name="Pipeline"
+ parent="crm_menu_report"
+ action="crm_opportunity_report_action"
+ sequence="2"/>
<record id="crm_opportunity_report_action_graph" model="ir.actions.act_window">
<field name="name">Pipeline Analysis</field>
@@ -135,12 +141,14 @@ teams of the sales pipeline.</field>
<field name="view_ids"
eval="[(5, 0, 0),
(0, 0, {'view_mode': 'graph', 'view_id': ref('crm_opportunity_report_view_graph')}),
- (0, 0, {'view_mode': 'pivot', 'view_id': ref('crm_opportunity_report_view_pivot')})]"/>
+ (0, 0, {'view_mode': 'pivot', 'view_id': ref('crm_opportunity_report_view_pivot')}),
+ (0, 0, {'view_mode': 'tree', 'view_id': ref('crm.crm_case_tree_view_oppor')}),
+ (0, 0, {'view_mode': 'form', 'view_id': ref('crm_case_form_view_oppor')})]"/>
<field name="help">Pipeline Analysis gives you an instant access to
-your opportunities with information such as the expected revenue, planned cost,
-missed deadlines or the number of interactions per opportunity. This report is
-mainly used by the sales manager in order to periodically review the pipeline
-with the the Sales Team.</field>
+ your opportunities with information such as the expected revenue, planned cost,
+ missed deadlines or the number of interactions per opportunity. This report is
+ mainly used by the sales manager in order to periodically review the pipeline
+ with the the Sales Team.</field>
</record>
<record id="crm_opportunity_report_action_lead" model="ir.actions.act_window">
diff --git a/addons/crm/views/crm_lead_views.xml b/addons/crm/views/crm_lead_views.xml
index a55d2a3e94b..59ec5555dc6 100644
--- a/addons/crm/views/crm_lead_views.xml
+++ b/addons/crm/views/crm_lead_views.xml
@@ -8,10 +8,12 @@
<field name="model">crm.lead.tag</field>
<field name="arch" type="xml">
<form string="Lead Tags">
- <group>
- <field name="name"/>
- <field name="color" groups="base.group_no_one"/>
- </group>
+ <sheet>
+ <group>
+ <field name="name"/>
+ <field name="color" groups="base.group_no_one"/>
+ </group>
+ </sheet>
</form>
</field>
</record>
@@ -649,7 +651,7 @@
<field name="name">crm.lead.view.pivot</field>
<field name="model">crm.lead</field>
<field name="arch" type="xml">
- <pivot string="Pipeline Analysis" disable_linking="True">
+ <pivot string="Pipeline Analysis">
<field name="create_date" interval="month" type="row"/>
<field name="stage_id" type="col"/>
<field name="planned_revenue" type="measure"/>
@@ -762,10 +764,12 @@
<field name="model">crm.lost.reason</field>
<field name="arch" type="xml">
<form string="Channel">
- <group>
- <field name="name"/>
- <field name="active"/>
- </group>
+ <sheet>
+ <group>
+ <field name="name"/>
+ <field name="active"/>
+ </group>
+ </sheet>
</form>
</field>
</record>
diff --git a/addons/crm/views/crm_stage_views.xml b/addons/crm/views/crm_stage_views.xml
index 0eb49e3756a..5f911780f51 100644
--- a/addons/crm/views/crm_stage_views.xml
+++ b/addons/crm/views/crm_stage_views.xml
@@ -33,20 +33,22 @@
<field name="priority" eval="1"/>
<field name="arch" type="xml">
<form string="Stage">
- <group>
- <field name="team_count" invisible="1"/>
+ <sheet>
<group>
- <field name="name"/>
- <field name="team_id" options='{"no_open": True, "no_create": True}' attrs="{'invisible': [('team_count', '&lt;=', 1)]}"/>
- <field name="fold"/>
+ <field name="team_count" invisible="1"/>
+ <group>
+ <field name="name"/>
+ <field name="team_id" options='{"no_open": True, "no_create": True}' attrs="{'invisible': [('team_count', '&lt;=', 1)]}"/>
+ <field name="fold"/>
+ </group>
+ <group>
+ <field name="on_change"/>
+ <field name="probability" attrs="{'invisible': [('on_change', '=', False)]}"/>
+ </group>
</group>
- <group>
- <field name="on_change"/>
- <field name="probability" attrs="{'invisible': [('on_change', '=', False)]}"/>
- </group>
- </group>
- <separator string="Requirements"/>
- <field name="requirements" nolabel="1" placeholder="Give your team the requirements to move an opportunity to this stage."/>
+ <separator string="Requirements"/>
+ <field name="requirements" nolabel="1" placeholder="Give your team the requirements to move an opportunity to this stage."/>
+ </sheet>
</form>
</field>
</record>
diff --git a/addons/crm/views/res_config_settings_views.xml b/addons/crm/views/res_config_settings_views.xml
index 518bbc313e3..2b8f8ac4953 100644
--- a/addons/crm/views/res_config_settings_views.xml
+++ b/addons/crm/views/res_config_settings_views.xml
@@ -25,8 +25,7 @@
</div>
<div class="col-12 col-lg-6 o_setting_box" id="crm_lead"
attrs="{'invisible': [('group_use_lead','=',False)]}"
- title="Emails received to that address generate new leads not assigned to any Sales Team yet. This can be made when converting them into opportunities. Incoming emails can be automatically assigned to specific Sales Teams. To do so, set an email alias on the Sales Team."
- groups="base.group_no_one">
+ title="Emails received to that address generate new leads not assigned to any Sales Team yet. This can be made when converting them into opportunities. Incoming emails can be automatically assigned to specific Sales Teams. To do so, set an email alias on the Sales Team.">
<div class="o_setting_left_pane">
<field name="generate_lead_from_alias"/>
</div>
diff --git a/addons/delivery/views/delivery_view.xml b/addons/delivery/views/delivery_view.xml
index 36c0b222064..495ce53448a 100644
--- a/addons/delivery/views/delivery_view.xml
+++ b/addons/delivery/views/delivery_view.xml
@@ -203,10 +203,8 @@
<field name="inherit_id" ref="stock.view_picking_form"/>
<field name="arch" type="xml">
<data>
- <xpath expr="//page[@name='extra']" position="inside">
- <separator string="Delivery Information"/>
- <group>
- <group name='carrier_data'>
+ <xpath expr="//group[@name='other_infos']" position="before">
+ <group name='carrier_data' string="Delivery Information">
<field name="carrier_id" attrs="{'readonly': [('state', 'in', ('done', 'cancel'))]}" options="{'no_create': True, 'no_open': True}"/>
<field name="delivery_type" attrs="{'invisible':True}"/>
<label for="carrier_tracking_ref"/>
@@ -214,8 +212,6 @@
<field name="carrier_tracking_ref" class="oe_inline" attrs="{'readonly': [('state', 'in', ('done', 'cancel'))]}"/>
<button type='object' class="fa fa-arrow-right oe_link" name="cancel_shipment" string="Cancel" attrs="{'invisible':['|','|','|',('carrier_tracking_ref','=',False),('delivery_type','in', ['fixed', 'base_on_rule']),('delivery_type','=',False),('state','not in',('done'))]}"/>
</div>
- </group>
- <group>
<label for="weight" string="Weight"/>
<div>
<field name="weight" class="oe_inline"/>
@@ -223,7 +219,6 @@
</div>
<field name="shipping_weight"/>
</group>
- </group>
</xpath>
<div name="button_box" position="inside">
<button type="object" name="open_website_url" class="oe_stat_button" icon='fa-truck' string="Tracking"
diff --git a/addons/digest/__manifest__.py b/addons/digest/__manifest__.py
index 4cafa7a2cbc..024305a0dca 100644
--- a/addons/digest/__manifest__.py
+++ b/addons/digest/__manifest__.py
@@ -10,7 +10,8 @@ Send KPI Digests periodically
'version': '1.0',
'depends': [
'mail',
- 'portal'
+ 'portal',
+ 'resource',
],
'data': [
'security/ir.model.access.csv',
diff --git a/addons/digest/models/digest.py b/addons/digest/models/digest.py
index dcbd9e6bd27..9446f5454a2 100644
--- a/addons/digest/models/digest.py
+++ b/addons/digest/models/digest.py
@@ -158,8 +158,7 @@ class Digest(models.Model):
def _compute_timeframes(self, company):
now = datetime.utcnow()
- # TODO remove hasattr in >=saas-12.1
- tz_name = hasattr(company, "resource_calendar_id") and company.resource_calendar_id.tz
+ tz_name = company.resource_calendar_id.tz
if tz_name:
now = pytz.timezone(tz_name).localize(now)
start_date = now.date()
diff --git a/addons/event/views/event_views.xml b/addons/event/views/event_views.xml
index 010f9f65d26..87f29e82f5d 100644
--- a/addons/event/views/event_views.xml
+++ b/addons/event/views/event_views.xml
@@ -404,7 +404,7 @@
<field name="name">event.event.view.pivot</field>
<field name="model">event.event</field>
<field name="arch" type="xml">
- <pivot string="Event" disable_linking="True">
+ <pivot string="Event">
<field name="name" type="row"/>
<field name="seats_reserved" type="measure"/>
</pivot>
diff --git a/addons/fetchmail/models/fetchmail.py b/addons/fetchmail/models/fetchmail.py
index f869afc8b7a..d04641bb450 100644
--- a/addons/fetchmail/models/fetchmail.py
+++ b/addons/fetchmail/models/fetchmail.py
@@ -33,11 +33,11 @@ class FetchmailServer(models.Model):
], string='Status', index=True, readonly=True, copy=False, default='draft')
server = fields.Char(string='Server Name', readonly=True, help="Hostname or IP of the mail server", states={'draft': [('readonly', False)]})
port = fields.Integer(readonly=True, states={'draft': [('readonly', False)]})
- type = fields.Selection([
+ server_type = fields.Selection([
('pop', 'POP Server'),
('imap', 'IMAP Server'),
('local', 'Local Server'),
- ], 'Server Type', index=True, required=True, default='pop')
+ ], string='Server Type', index=True, required=True, default='pop', oldname='type')
is_ssl = fields.Boolean('SSL/TLS', help="Connections are encrypted with SSL/TLS through a dedicated port (default: IMAPS=993, POP3S=995)")
attach = fields.Boolean('Keep Attachments', help="Whether attachments should be downloaded. "
"If not enabled, incoming emails will be stripped of any attachments before being processed", default=True)
@@ -55,12 +55,12 @@ class FetchmailServer(models.Model):
configuration = fields.Text('Configuration', readonly=True)
script = fields.Char(readonly=True, default='/mail/static/scripts/openerp_mailgate.py')
- @api.onchange('type', 'is_ssl', 'object_id')
+ @api.onchange('server_type', 'is_ssl', 'object_id')
def onchange_server_type(self):
self.port = 0
- if self.type == 'pop':
+ if self.server_type == 'pop':
self.port = self.is_ssl and 995 or 110
- elif self.type == 'imap':
+ elif self.server_type == 'imap':
self.port = self.is_ssl and 993 or 143
else:
self.server = ''
@@ -106,13 +106,13 @@ class FetchmailServer(models.Model):
@api.multi
def connect(self):
self.ensure_one()
- if self.type == 'imap':
+ if self.server_type == 'imap':
if self.is_ssl:
connection = IMAP4_SSL(self.server, int(self.port))
else:
connection = IMAP4(self.server, int(self.port))
connection.login(self.user, self.password)
- elif self.type == 'pop':
+ elif self.server_type == 'pop':
if self.is_ssl:
connection = POP3_SSL(self.server, int(self.port))
else:
@@ -132,14 +132,14 @@ class FetchmailServer(models.Model):
connection = server.connect()
server.write({'state': 'done'})
except Exception as err:
- _logger.info("Failed to connect to %s server %s.", server.type, server.name, exc_info=True)
+ _logger.info("Failed to connect to %s server %s.", server.server_type, server.name, exc_info=True)
raise UserError(_("Connection test failed: %s") % tools.ustr(err))
finally:
try:
if connection:
- if server.type == 'imap':
+ if server.server_type == 'imap':
connection.close()
- elif server.type == 'pop':
+ elif server.server_type == 'pop':
connection.quit()
except Exception:
# ignored, just a consequence of the previous exception
@@ -149,7 +149,7 @@ class FetchmailServer(models.Model):
@api.model
def _fetch_mails(self):
""" Method called by cron to fetch mails from servers """
- return self.search([('state', '=', 'done'), ('type', 'in', ['pop', 'imap'])]).fetch_mail()
+ return self.search([('state', '=', 'done'), ('server_type', 'in', ['pop', 'imap'])]).fetch_mail()
@api.multi
def fetch_mail(self):
@@ -159,13 +159,13 @@ class FetchmailServer(models.Model):
}
MailThread = self.env['mail.thread']
for server in self:
- _logger.info('start checking for new emails on %s server %s', server.type, server.name)
+ _logger.info('start checking for new emails on %s server %s', server.server_type, server.name)
additionnal_context['fetchmail_server_id'] = server.id
- additionnal_context['server_type'] = server.type
+ additionnal_context['server_type'] = server.server_type
count, failed = 0, 0
imap_server = None
pop_server = None
- if server.type == 'imap':
+ if server.server_type == 'imap':
try:
imap_server = server.connect()
imap_server.select()
@@ -177,19 +177,19 @@ class FetchmailServer(models.Model):
try:
res_id = MailThread.with_context(**additionnal_context).message_process(server.object_id.model, data[0][1], save_original=server.original, strip_attachments=(not server.attach))
except Exception:
- _logger.info('Failed to process mail from %s server %s.', server.type, server.name, exc_info=True)
+ _logger.info('Failed to process mail from %s server %s.', server.server_type, server.name, exc_info=True)
failed += 1
imap_server.store(num, '+FLAGS', '\\Seen')
self._cr.commit()
count += 1
- _logger.info("Fetched %d email(s) on %s server %s; %d succeeded, %d failed.", count, server.type, server.name, (count - failed), failed)
+ _logger.info("Fetched %d email(s) on %s server %s; %d succeeded, %d failed.", count, server.server_type, server.name, (count - failed), failed)
except Exception:
- _logger.info("General failure when trying to fetch mail from %s server %s.", server.type, server.name, exc_info=True)
+ _logger.info("General failure when trying to fetch mail from %s server %s.", server.server_type, server.name, exc_info=True)
finally:
if imap_server:
imap_server.close()
imap_server.logout()
- elif server.type == 'pop':
+ elif server.server_type == 'pop':
try:
while True:
pop_server = server.connect()
@@ -203,15 +203,15 @@ class FetchmailServer(models.Model):
res_id = MailThread.with_context(**additionnal_context).message_process(server.object_id.model, message, save_original=server.original, strip_attachments=(not server.attach))
pop_server.dele(num)
except Exception:
- _logger.info('Failed to process mail from %s server %s.', server.type, server.name, exc_info=True)
+ _logger.info('Failed to process mail from %s server %s.', server.server_type, server.name, exc_info=True)
failed += 1
self.env.cr.commit()
if num_messages < MAX_POP_MESSAGES:
break
pop_server.quit()
- _logger.info("Fetched %d email(s) on %s server %s; %d succeeded, %d failed.", num_messages, server.type, server.name, (num_messages - failed), failed)
+ _logger.info("Fetched %d email(s) on %s server %s; %d succeeded, %d failed.", num_messages, server.server_type, server.name, (num_messages - failed), failed)
except Exception:
- _logger.info("General failure when trying to fetch mail from %s server %s.", server.type, server.name, exc_info=True)
+ _logger.info("General failure when trying to fetch mail from %s server %s.", server.server_type, server.name, exc_info=True)
finally:
if pop_server:
pop_server.quit()
@@ -225,6 +225,6 @@ class FetchmailServer(models.Model):
try:
# Enabled/Disable cron based on the number of 'done' server of type pop or imap
cron = self.env.ref('fetchmail.ir_cron_mail_gateway_action')
- cron.toggle(model=self._name, domain=[('state', '=', 'done'), ('type', 'in', ['pop', 'imap'])])
+ cron.toggle(model=self._name, domain=[('state', '=', 'done'), ('server_type', 'in', ['pop', 'imap'])])
except ValueError:
pass
diff --git a/addons/fetchmail/views/fetchmail_views.xml b/addons/fetchmail/views/fetchmail_views.xml
index 9bbe6c7ea5d..881aebf717d 100644
--- a/addons/fetchmail/views/fetchmail_views.xml
+++ b/addons/fetchmail/views/fetchmail_views.xml
@@ -7,7 +7,7 @@
<field name="arch" type="xml">
<tree decoration-info="state == 'draft'" string="POP/IMAP Servers">
<field name="name"/>
- <field name="type"/>
+ <field name="server_type"/>
<field name="is_ssl"/>
<field name="object_id"/>
<field name="date"/>
@@ -22,7 +22,7 @@
<field name="model">fetchmail.server</field>
<field name="arch" type="xml">
<form string="Incoming Mail Server">
- <header attrs="{'invisible' : [('type', '=', 'local')]}">
+ <header attrs="{'invisible' : [('server_type', '=', 'local')]}">
<button string="Test &amp; Confirm" type="object" name="button_confirm_login" states="draft"/>
<button string="Fetch Now" type="object" name="fetch_mail" states="done"/>
<button string="Reset Confirmation" type="object" name="set_draft" states="done"/>
@@ -31,25 +31,25 @@
<sheet>
<group col="4">
<field name="name"/>
- <field name="type"/>
+ <field name="server_type"/>
<field name="date"/>
</group>
<notebook>
<page string="Server &amp; Login">
<group>
- <group attrs="{'invisible' : [('type', '=', 'local')]}" string="Server Information">
- <field name="server" colspan="2" attrs="{'required' : [('type', '!=', 'local')]}" />
- <field name="port" required="1" attrs="{'required' : [('type', '!=', 'local')]}" />
+ <group attrs="{'invisible' : [('server_type', '=', 'local')]}" string="Server Information">
+ <field name="server" colspan="2" attrs="{'required' : [('server_type', '!=', 'local')]}" />
+ <field name="port" required="1" attrs="{'required' : [('server_type', '!=', 'local')]}" />
<field name="is_ssl"/>
</group>
- <group attrs="{'invisible' : [('type', '=', 'local')]}" string="Login Information">
- <field name="user" attrs="{'required' : [('type', '!=', 'local')]}"/>
- <field name="password" password="True" attrs="{'required' : [('type', '!=', 'local')]}"/>
+ <group attrs="{'invisible' : [('server_type', '=', 'local')]}" string="Login Information">
+ <field name="user" attrs="{'required' : [('server_type', '!=', 'local')]}"/>
+ <field name="password" password="True" attrs="{'required' : [('server_type', '!=', 'local')]}"/>
</group>
<group string="Actions to Perform on Incoming Mails">
<field name="object_id"/>
</group>
- <group attrs="{'invisible' : [('type', '!=', 'local')]}" string="Configuration">
+ <group attrs="{'invisible' : [('server_type', '!=', 'local')]}" string="Configuration">
<field name="configuration" colspan="4"/>
<field name="script" colspan="4" widget="url"/>
</group>
@@ -75,8 +75,8 @@
<field name="arch" type="xml">
<search string="Search Incoming Mail Servers">
<field name="name" string="Incoming Mail Server"/>
- <filter string="IMAP" name="imap" domain="[('type','=','imap')]" help="Server type IMAP."/>
- <filter string="POP" name="pop" domain="[('type','=','pop')]" help="Server type POP."/>
+ <filter string="IMAP" name="imap" domain="[('server_type','=','imap')]" help="Server type IMAP."/>
+ <filter string="POP" name="pop" domain="[('server_type','=','pop')]" help="Server type POP."/>
<separator/>
<filter string="SSL" name="ssl" domain="[('is_ssl','=',True)]" help="If SSL required."/>
</search>
diff --git a/addons/fleet/data/fleet_cars_data.xml b/addons/fleet/data/fleet_cars_data.xml
index 6e6b2081752..34412ec64c2 100644
--- a/addons/fleet/data/fleet_cars_data.xml
+++ b/addons/fleet/data/fleet_cars_data.xml
@@ -264,4 +264,184 @@
<field name="name">Suzuki</field>
<field name="image" type="base64" file="fleet/static/img/brand_suzuki-image.png"/>
</record>
+ <record id="model_corsa" model="fleet.vehicle.model">
+ <field name="name">Corsa</field>
+ <field name="brand_id" ref="brand_opel" />
+ </record>
+ <record id="model_astra" model="fleet.vehicle.model">
+ <field name="name">Astra</field>
+ <field name="brand_id" ref="brand_opel" />
+ </record>
+ <record id="model_agila" model="fleet.vehicle.model">
+ <field name="name">Agila</field>
+ <field name="brand_id" ref="brand_opel" />
+ </record>
+ <record id="model_combotour" model="fleet.vehicle.model">
+ <field name="name">Combo Tour</field>
+ <field name="brand_id" ref="brand_opel" />
+ </record>
+ <record id="model_meriva" model="fleet.vehicle.model">
+ <field name="name">Meriva</field>
+ <field name="brand_id" ref="brand_opel" />
+ </record>
+ <record id="model_astragtc" model="fleet.vehicle.model">
+ <field name="name">AstraGTC</field>
+ <field name="brand_id" ref="brand_opel" />
+ </record>
+ <record id="model_zafira" model="fleet.vehicle.model">
+ <field name="name">Zafira</field>
+ <field name="brand_id" ref="brand_opel" />
+ </record>
+ <record id="model_zafiratourer" model="fleet.vehicle.model">
+ <field name="name">Zafira Tourer</field>
+ <field name="brand_id" ref="brand_opel" />
+ </record>
+ <record id="model_insignia" model="fleet.vehicle.model">
+ <field name="name">Insignia</field>
+ <field name="brand_id" ref="brand_opel" />
+ </record>
+ <record id="model_mokka" model="fleet.vehicle.model">
+ <field name="name">Mokka</field>
+ <field name="brand_id" ref="brand_opel" />
+ </record>
+ <record id="model_antara" model="fleet.vehicle.model">
+ <field name="name">Antara</field>
+ <field name="brand_id" ref="brand_opel" />
+ </record>
+ <record id="model_ampera" model="fleet.vehicle.model">
+ <field name="name">Ampera</field>
+ <field name="brand_id" ref="brand_opel" />
+ </record>
+ <record id="model_a1" model="fleet.vehicle.model">
+ <field name="name">A1</field>
+ <field name="brand_id" ref="brand_audi" />
+ </record>
+ <record id="model_a3" model="fleet.vehicle.model">
+ <field name="name">A3</field>
+ <field name="brand_id" ref="brand_audi" />
+ </record>
+ <record id="model_a4" model="fleet.vehicle.model">
+ <field name="name">A4</field>
+ <field name="brand_id" ref="brand_audi" />
+ </record>
+ <record id="model_a5" model="fleet.vehicle.model">
+ <field name="name">A5</field>
+ <field name="brand_id" ref="brand_audi" />
+ </record>
+ <record id="model_a6" model="fleet.vehicle.model">
+ <field name="name">A6</field>
+ <field name="brand_id" ref="brand_audi" />
+ </record>
+ <record id="model_a7" model="fleet.vehicle.model">
+ <field name="name">A7</field>
+ <field name="brand_id" ref="brand_audi" />
+ </record>
+ <record id="model_a8" model="fleet.vehicle.model">
+ <field name="name">A8</field>
+ <field name="brand_id" ref="brand_audi" />
+ </record>
+ <record id="model_q3" model="fleet.vehicle.model">
+ <field name="name">Q3</field>
+ <field name="brand_id" ref="brand_audi" />
+ </record>
+ <record id="model_q5" model="fleet.vehicle.model">
+ <field name="name">Q5</field>
+ <field name="brand_id" ref="brand_audi" />
+ </record>
+ <record id="model_q7" model="fleet.vehicle.model">
+ <field name="name">Q7</field>
+ <field name="brand_id" ref="brand_audi" />
+ </record>
+ <record id="model_tt" model="fleet.vehicle.model">
+ <field name="name">TT</field>
+ <field name="brand_id" ref="brand_audi" />
+ </record>
+ <record id="model_serie1" model="fleet.vehicle.model">
+ <field name="name">Serie 1</field>
+ <field name="brand_id" ref="brand_bmw" />
+ </record>
+ <record id="model_serie3" model="fleet.vehicle.model">
+ <field name="name">Serie 3</field>
+ <field name="brand_id" ref="brand_bmw" />
+ </record>
+ <record id="model_serie5" model="fleet.vehicle.model">
+ <field name="name">Serie 5</field>
+ <field name="brand_id" ref="brand_bmw" />
+ </record>
+ <record id="model_serie6" model="fleet.vehicle.model">
+ <field name="name">Serie 6</field>
+ <field name="brand_id" ref="brand_bmw" />
+ </record>
+ <record id="model_serie7" model="fleet.vehicle.model">
+ <field name="name">Serie 7</field>
+ <field name="brand_id" ref="brand_bmw" />
+ </record>
+ <record id="model_seriex" model="fleet.vehicle.model">
+ <field name="name">Serie X</field>
+ <field name="brand_id" ref="brand_bmw" />
+ </record>
+ <record id="model_seriez4" model="fleet.vehicle.model">
+ <field name="name">Serie Z4</field>
+ <field name="brand_id" ref="brand_bmw" />
+ </record>
+ <record id="model_seriem" model="fleet.vehicle.model">
+ <field name="name">Serie M</field>
+ <field name="brand_id" ref="brand_bmw" />
+ </record>
+ <record id="model_seriehybrid" model="fleet.vehicle.model">
+ <field name="name">Serie Hybrid</field>
+ <field name="brand_id" ref="brand_bmw" />
+ </record>
+ <record id="model_classa" model="fleet.vehicle.model">
+ <field name="name">Class A</field>
+ <field name="brand_id" ref="brand_mercedes" />
+ </record>
+ <record id="model_classb" model="fleet.vehicle.model">
+ <field name="name">Class B</field>
+ <field name="brand_id" ref="brand_mercedes" />
+ </record>
+ <record id="model_classc" model="fleet.vehicle.model">
+ <field name="name">Class C</field>
+ <field name="brand_id" ref="brand_mercedes" />
+ </record>
+ <record id="model_classcl" model="fleet.vehicle.model">
+ <field name="name">Class CL</field>
+ <field name="brand_id" ref="brand_mercedes" />
+ </record>
+ <record id="model_classcls" model="fleet.vehicle.model">
+ <field name="name">Class CLS</field>
+ <field name="brand_id" ref="brand_mercedes" />
+ </record>
+ <record id="model_classe" model="fleet.vehicle.model">
+ <field name="name">Class E</field>
+ <field name="brand_id" ref="brand_mercedes" />
+ </record>
+ <record id="model_classm" model="fleet.vehicle.model">
+ <field name="name">Class M</field>
+ <field name="brand_id" ref="brand_mercedes" />
+ </record>
+ <record id="model_classgl" model="fleet.vehicle.model">
+ <field name="name">Class GL</field>
+ <field name="brand_id" ref="brand_mercedes" />
+ </record>
+ <record id="model_classglk" model="fleet.vehicle.model">
+ <field name="name">Class GLK</field>
+ <field name="brand_id" ref="brand_mercedes" />
+ </record>
+ <record id="model_classr" model="fleet.vehicle.model">
+ <field name="name">Class R</field>
+ <field name="brand_id" ref="brand_mercedes" />
+ </record>
+ <record id="model_classs" model="fleet.vehicle.model">
+ <field name="name">Class S</field>
+ <field name="brand_id" ref="brand_mercedes" />
+ </record>
+ <record id="model_classslk" model="fleet.vehicle.model">
+ <field name="name">Class SLK</field>
+ <field name="brand_id" ref="brand_mercedes" />
+ </record>
+ <record id="model_classsls" model="fleet.vehicle.model">
+ <field name="name">SLS</field>
+ <field name="brand_id" ref="brand_mercedes" />
+ </record>
</odoo>
diff --git a/addons/fleet/data/fleet_demo.xml b/addons/fleet/data/fleet_demo.xml
index e981e24057d..21c550bf977 100644
--- a/addons/fleet/data/fleet_demo.xml
+++ b/addons/fleet/data/fleet_demo.xml
@@ -446,231 +446,6 @@
<field name="color" eval="8"/>
</record>
- <record id="model_corsa" model="fleet.vehicle.model">
- <field name="name">Corsa</field>
- <field name="brand_id" ref="brand_opel" />
- </record>
-
- <record id="model_astra" model="fleet.vehicle.model">
- <field name="name">Astra</field>
- <field name="brand_id" ref="brand_opel" />
- </record>
-
- <record id="model_agila" model="fleet.vehicle.model">
- <field name="name">Agila</field>
- <field name="brand_id" ref="brand_opel" />
- </record>
-
- <record id="model_combotour" model="fleet.vehicle.model">
- <field name="name">Combo Tour</field>
- <field name="brand_id" ref="brand_opel" />
- </record>
-
- <record id="model_meriva" model="fleet.vehicle.model">
- <field name="name">Meriva</field>
- <field name="brand_id" ref="brand_opel" />
- </record>
-
- <record id="model_astragtc" model="fleet.vehicle.model">
- <field name="name">AstraGTC</field>
- <field name="brand_id" ref="brand_opel" />
- </record>
-
- <record id="model_zafira" model="fleet.vehicle.model">
- <field name="name">Zafira</field>
- <field name="brand_id" ref="brand_opel" />
- </record>
-
- <record id="model_zafiratourer" model="fleet.vehicle.model">
- <field name="name">Zafira Tourer</field>
- <field name="brand_id" ref="brand_opel" />
- </record>
-
- <record id="model_insignia" model="fleet.vehicle.model">
- <field name="name">Insignia</field>
- <field name="brand_id" ref="brand_opel" />
- </record>
-
- <record id="model_mokka" model="fleet.vehicle.model">
- <field name="name">Mokka</field>
- <field name="brand_id" ref="brand_opel" />
- </record>
-
- <record id="model_antara" model="fleet.vehicle.model">
- <field name="name">Antara</field>
- <field name="brand_id" ref="brand_opel" />
- </record>
-
- <record id="model_ampera" model="fleet.vehicle.model">
- <field name="name">Ampera</field>
- <field name="brand_id" ref="brand_opel" />
- </record>
-
- <record id="model_a1" model="fleet.vehicle.model">
- <field name="name">A1</field>
- <field name="brand_id" ref="brand_audi" />
- </record>
-
- <record id="model_a3" model="fleet.vehicle.model">
- <field name="name">A3</field>
- <field name="brand_id" ref="brand_audi" />
- </record>
-
- <record id="model_a4" model="fleet.vehicle.model">
- <field name="name">A4</field>
- <field name="brand_id" ref="brand_audi" />
- </record>
-
- <record id="model_a5" model="fleet.vehicle.model">
- <field name="name">A5</field>
- <field name="brand_id" ref="brand_audi" />
- </record>
-
- <record id="model_a6" model="fleet.vehicle.model">
- <field name="name">A6</field>
- <field name="brand_id" ref="brand_audi" />
- </record>
-
- <record id="model_a7" model="fleet.vehicle.model">
- <field name="name">A7</field>
- <field name="brand_id" ref="brand_audi" />
- </record>
-
- <record id="model_a8" model="fleet.vehicle.model">
- <field name="name">A8</field>
- <field name="brand_id" ref="brand_audi" />
- </record>
-
- <record id="model_q3" model="fleet.vehicle.model">
- <field name="name">Q3</field>
- <field name="brand_id" ref="brand_audi" />
- </record>
-
- <record id="model_q5" model="fleet.vehicle.model">
- <field name="name">Q5</field>
- <field name="brand_id" ref="brand_audi" />
- </record>
-
- <record id="model_q7" model="fleet.vehicle.model">
- <field name="name">Q7</field>
- <field name="brand_id" ref="brand_audi" />
- </record>
-
- <record id="model_tt" model="fleet.vehicle.model">
- <field name="name">TT</field>
- <field name="brand_id" ref="brand_audi" />
- </record>
-
- <record id="model_serie1" model="fleet.vehicle.model">
- <field name="name">Serie 1</field>
- <field name="brand_id" ref="brand_bmw" />
- </record>
-
- <record id="model_serie3" model="fleet.vehicle.model">
- <field name="name">Serie 3</field>
- <field name="brand_id" ref="brand_bmw" />
- </record>
-
- <record id="model_serie5" model="fleet.vehicle.model">
- <field name="name">Serie 5</field>
- <field name="brand_id" ref="brand_bmw" />
- </record>
-
- <record id="model_serie6" model="fleet.vehicle.model">
- <field name="name">Serie 6</field>
- <field name="brand_id" ref="brand_bmw" />
- </record>
-
- <record id="model_serie7" model="fleet.vehicle.model">
- <field name="name">Serie 7</field>
- <field name="brand_id" ref="brand_bmw" />
- </record>
-
- <record id="model_seriex" model="fleet.vehicle.model">
- <field name="name">Serie X</field>
- <field name="brand_id" ref="brand_bmw" />
- </record>
-
- <record id="model_seriez4" model="fleet.vehicle.model">
- <field name="name">Serie Z4</field>
- <field name="brand_id" ref="brand_bmw" />
- </record>
-
- <record id="model_seriem" model="fleet.vehicle.model">
- <field name="name">Serie M</field>
- <field name="brand_id" ref="brand_bmw" />
- </record>
-
- <record id="model_seriehybrid" model="fleet.vehicle.model">
- <field name="name">Serie Hybrid</field>
- <field name="brand_id" ref="brand_bmw" />
- </record>
-
- <record id="model_classa" model="fleet.vehicle.model">
- <field name="name">Class A</field>
- <field name="brand_id" ref="brand_mercedes" />
- </record>
-
- <record id="model_classb" model="fleet.vehicle.model">
- <field name="name">Class B</field>
- <field name="brand_id" ref="brand_mercedes" />
- </record>
-
- <record id="model_classc" model="fleet.vehicle.model">
- <field name="name">Class C</field>
- <field name="brand_id" ref="brand_mercedes" />
- </record>
-
- <record id="model_classcl" model="fleet.vehicle.model">
- <field name="name">Class CL</field>
- <field name="brand_id" ref="brand_mercedes" />
- </record>
-
- <record id="model_classcls" model="fleet.vehicle.model">
- <field name="name">Class CLS</field>
- <field name="brand_id" ref="brand_mercedes" />
- </record>
-
- <record id="model_classe" model="fleet.vehicle.model">
- <field name="name">Class E</field>
- <field name="brand_id" ref="brand_mercedes" />
- </record>
-
- <record id="model_classm" model="fleet.vehicle.model">
- <field name="name">Class M</field>
- <field name="brand_id" ref="brand_mercedes" />
- </record>
-
- <record id="model_classgl" model="fleet.vehicle.model">
- <field name="name">Class GL</field>
- <field name="brand_id" ref="brand_mercedes" />
- </record>
-
- <record id="model_classglk" model="fleet.vehicle.model">
- <field name="name">Class GLK</field>
- <field name="brand_id" ref="brand_mercedes" />
- </record>
-
- <record id="model_classr" model="fleet.vehicle.model">
- <field name="name">Class R</field>
- <field name="brand_id" ref="brand_mercedes" />
- </record>
-
- <record id="model_classs" model="fleet.vehicle.model">
- <field name="name">Class S</field>
- <field name="brand_id" ref="brand_mercedes" />
- </record>
-
- <record id="model_classslk" model="fleet.vehicle.model">
- <field name="name">Class SLK</field>
- <field name="brand_id" ref="brand_mercedes" />
- </record>
-
- <record id="model_classsls" model="fleet.vehicle.model">
- <field name="name">SLS</field>
- <field name="brand_id" ref="brand_mercedes" />
- </record>
-
<record id="vehicle_1" model="fleet.vehicle">
<field name="license_plate">1-ACK-205</field>
<field name="vin_sn">5454541</field>
diff --git a/addons/fleet/models/fleet_vehicle.py b/addons/fleet/models/fleet_vehicle.py
index 7a00aea283f..1d03bc030f0 100644
--- a/addons/fleet/models/fleet_vehicle.py
+++ b/addons/fleet/models/fleet_vehicle.py
@@ -268,7 +268,7 @@ class FleetVehicle(models.Model):
def _track_subtype(self, init_values):
self.ensure_one()
if 'driver_id' in init_values:
- return 'fleet.mt_fleet_driver_updated'
+ return self.env.ref('fleet.mt_fleet_driver_updated')
return super(FleetVehicle, self)._track_subtype(init_values)
def open_assignation_logs(self):
diff --git a/addons/fleet/views/fleet_vehicle_model_views.xml b/addons/fleet/views/fleet_vehicle_model_views.xml
index b31bce5810a..4d8fba5c49b 100644
--- a/addons/fleet/views/fleet_vehicle_model_views.xml
+++ b/addons/fleet/views/fleet_vehicle_model_views.xml
@@ -99,15 +99,17 @@
<field name="model">fleet.vehicle.model.brand</field>
<field name="arch" type="xml">
<form string="Model Make">
- <group>
- <div>
- <field name="image_medium" widget="image" class="oe_avatar"/>
- <label for="name" class="oe_edit_only"/>
- <h1>
- <field name="name"/>
- </h1>
- </div>
- </group>
+ <sheet>
+ <group>
+ <div>
+ <field name="image_medium" widget="image" class="oe_avatar"/>
+ <label for="name" class="oe_edit_only"/>
+ <h1>
+ <field name="name"/>
+ </h1>
+ </div>
+ </group>
+ </sheet>
</form>
</field>
</record>
diff --git a/addons/fleet/views/fleet_vehicle_views.xml b/addons/fleet/views/fleet_vehicle_views.xml
index d111b710e5e..ee60f219bd2 100644
--- a/addons/fleet/views/fleet_vehicle_views.xml
+++ b/addons/fleet/views/fleet_vehicle_views.xml
@@ -437,9 +437,11 @@
<field name="model">fleet.vehicle.tag</field>
<field name="arch" type="xml">
<form string="Vehicle Tags">
- <group>
- <field name="name"/>
- </group>
+ <sheet>
+ <group>
+ <field name="name"/>
+ </group>
+ </sheet>
</form>
</field>
</record>
diff --git a/addons/google_calendar/models/google_calendar.py b/addons/google_calendar/models/google_calendar.py
index 8c4ee04489c..ac1793f428f 100644
--- a/addons/google_calendar/models/google_calendar.py
+++ b/addons/google_calendar/models/google_calendar.py
@@ -215,7 +215,7 @@ class GoogleCalendar(models.AbstractModel):
reminders = []
for alarm in event.alarm_ids:
reminders.append({
- "method": "email" if alarm.type == "email" else "popup",
+ "method": "email" if alarm.alarm_type == "email" else "popup",
"minutes": alarm.duration_minutes
})
data = {
@@ -458,13 +458,13 @@ class GoogleCalendar(models.AbstractModel):
for google_alarm in single_event_dict.get('reminders', {}).get('overrides', []):
alarm = CalendarAlarm.search(
[
- ('type', '=', google_alarm['method'] if google_alarm['method'] == 'email' else 'notification'),
+ ('alarm_type', '=', google_alarm['method'] if google_alarm['method'] == 'email' else 'notification'),
('duration_minutes', '=', google_alarm['minutes'])
], limit=1
)
if not alarm:
data = {
- 'type': google_alarm['method'] if google_alarm['method'] == 'email' else 'notification',
+ 'alarm_type': google_alarm['method'] if google_alarm['method'] == 'email' else 'notification',
'duration': google_alarm['minutes'],
'interval': 'minutes',
'name': "%s minutes - %s" % (google_alarm['minutes'], google_alarm['method'])
diff --git a/addons/hr/data/hr_demo.xml b/addons/hr/data/hr_demo.xml
index 853ce9f2c72..6a57335ad97 100644
--- a/addons/hr/data/hr_demo.xml
+++ b/addons/hr/data/hr_demo.xml
@@ -170,7 +170,7 @@ If you have development competencies, we can propose you specific traineeships</
<record id="employee_niv" model="hr.employee">
<field name="name">Sharlene Rhodes</field>
- <field name="department_id" ref="dep_rd"/>
+ <field name="department_id" ref="dep_management"/>
<field name="parent_id" ref="employee_al"/>
<field name="job_id" ref="hr.job_developer"/>
<field name="category_ids" eval="[(6, 0, [ref('employee_category_4')])]"/>
@@ -289,7 +289,7 @@ If you have development competencies, we can propose you specific traineeships</
<record id="employee_jve" model="hr.employee">
<field name="name">Paul Williams</field>
- <field name="department_id" ref="dep_rd"/>
+ <field name="department_id" ref="dep_management"/>
<field name="job_id" ref="hr.job_developer"/>
<field name="work_location">Building 1, Second Floor</field>
<field name="work_phone">(114)-262-1607</field>
diff --git a/addons/hr/models/res_config_settings.py b/addons/hr/models/res_config_settings.py
index 4512d6e04f3..ea96590434c 100644
--- a/addons/hr/models/res_config_settings.py
+++ b/addons/hr/models/res_config_settings.py
@@ -10,3 +10,7 @@ class ResConfigSettings(models.TransientModel):
'resource.calendar', 'Company Working Hours',
related='company_id.resource_calendar_id', readonly=False)
module_hr_org_chart = fields.Boolean(string="Show Organizational Chart")
+ module_hr_presence = fields.Boolean(string="Control presence of employees")
+ hr_presence_control_login = fields.Boolean(string="According to the system login (User status on chat)", config_parameter='hr.hr_presence_control_login')
+ hr_presence_control_email = fields.Boolean(string="According to the amount of sent emails", config_parameter='hr.hr_presence_control_email')
+ hr_presence_control_ip = fields.Boolean(string="According to the IP address", config_parameter='hr.hr_presence_control_ip')
diff --git a/addons/hr/views/hr_views.xml b/addons/hr/views/hr_views.xml
index 2f9733ee443..62722d90330 100644
--- a/addons/hr/views/hr_views.xml
+++ b/addons/hr/views/hr_views.xml
@@ -177,7 +177,7 @@
<field name="arch" type="xml">
<search string="Employees">
<field name="name" string="Employees" filter_domain="['|',('work_email','ilike',self),('name','ilike',self)]"/>
- <field name="department_id"/>
+ <field name="department_id" operator="child_of"/>
<field name="category_ids" groups="hr.group_hr_user"/>
<field name="job_id" />
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
@@ -318,9 +318,11 @@
<field name="model">hr.employee.category</field>
<field name="arch" type="xml">
<form string="Employee Tags">
- <group>
- <field name="name"/>
- </group>
+ <sheet>
+ <group>
+ <field name="name"/>
+ </group>
+ </sheet>
</form>
</field>
</record>
@@ -344,9 +346,17 @@
</record>
<menuitem
+ id="menu_human_resources_configuration_employee"
+ name="Employee"
+ parent="menu_human_resources_configuration"
+ groups="base.group_no_one"
+ sequence="1"/>
+
+ <menuitem
id="menu_view_employee_category_form"
+ name="Tags"
action="open_view_categ_form"
- parent="menu_human_resources_configuration"
+ parent="menu_human_resources_configuration_employee"
groups="base.group_no_one"
sequence="1"/>
@@ -438,7 +448,7 @@
<field name="name" string="Job"/>
<filter domain="[('state','=','open')]" name="in_position" string="In Position"/>
<filter domain="[('state','=','recruit')]" string="In Recruitment" name="in_recruitment"/>
- <field name="department_id"/>
+ <field name="department_id" operator="child_of"/>
<separator/>
<filter string="Unread Messages" name="message_needaction" domain="[('message_needaction','=',True)]"/>
<group expand="0" string="Group By">
diff --git a/addons/hr_attendance/views/hr_attendance_view.xml b/addons/hr_attendance/views/hr_attendance_view.xml
index 4c7b33c0980..a3b7ca51c42 100644
--- a/addons/hr_attendance/views/hr_attendance_view.xml
+++ b/addons/hr_attendance/views/hr_attendance_view.xml
@@ -67,7 +67,7 @@
<field name="arch" type="xml">
<search string="Hr Attendance Search">
<field name="employee_id"/>
- <field name="department_id"/>
+ <field name="department_id" operator="child_of"/>
<filter string="My Attendances" name="myattendances" domain="[('employee_id.user_id.id', '=', uid)]" />
<separator/>
<filter name="check_in_filter" string="Check In Date" date="check_in"/>
diff --git a/addons/hr_contract/models/hr_contract.py b/addons/hr_contract/models/hr_contract.py
index bb30f94856e..7cecfe096dc 100644
--- a/addons/hr_contract/models/hr_contract.py
+++ b/addons/hr_contract/models/hr_contract.py
@@ -6,10 +6,10 @@ from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
+from odoo.osv import expression
class Employee(models.Model):
-
_inherit = "hr.employee"
manager = fields.Boolean(string='Is a Manager')
@@ -32,6 +32,22 @@ class Employee(models.Model):
for employee in self:
employee.contracts_count = result.get(employee.id, 0)
+ def _get_contracts(self, date_from, date_to):
+ """
+ Returns the contracts of the employee between date_from and date_to
+ """
+ self.ensure_one()
+ # a contract is valid if it ends between the given dates
+ clause_1 = ['&', ('date_end', '<=', date_to), ('date_end', '>=', date_from)]
+ # OR if it starts between the given dates
+ clause_2 = ['&', ('date_start', '<=', date_to), ('date_start', '>=', date_from)]
+ # OR if it starts before the date_from and finish after the date_end (or never finish)
+ clause_3 = ['&', ('date_start', '<=', date_from), '|', ('date_end', '=', False), ('date_end', '>=', date_to)]
+ clause_final = expression.AND([
+ [('employee_id', '=', self.id), ('state', '=', 'open')],
+ expression.OR([clause_1, clause_2, clause_3])])
+ return self.env['hr.contract'].search(clause_final)
+
class ContractType(models.Model):
@@ -128,7 +144,7 @@ class Contract(models.Model):
def _track_subtype(self, init_values):
self.ensure_one()
if 'state' in init_values and self.state == 'pending':
- return 'hr_contract.mt_contract_pending'
+ return self.env.ref('hr_contract.mt_contract_pending')
elif 'state' in init_values and self.state == 'close':
- return 'hr_contract.mt_contract_close'
+ return self.env.ref('hr_contract.mt_contract_close')
return super(Contract, self)._track_subtype(init_values)
diff --git a/addons/hr_contract/views/hr_contract_views.xml b/addons/hr_contract/views/hr_contract_views.xml
index 537988666f1..0ae68e6c4e4 100644
--- a/addons/hr_contract/views/hr_contract_views.xml
+++ b/addons/hr_contract/views/hr_contract_views.xml
@@ -54,7 +54,7 @@
<field name="date_end"/>
<field name="resource_calendar_id"/>
<field name="employee_id"/>
- <field name="department_id"/>
+ <field name="department_id" operator="child_of"/>
<field name="state"/>
<filter string="To Renew" name="to_renew" domain="[('state', '=', 'pending')]"/>
<separator />
@@ -68,7 +68,7 @@
<group expand="0" string="Group By">
<filter string="Employee" name="employee" domain="[]" context="{'group_by':'employee_id'}"/>
<filter string="Job" name="job" domain="[]" context="{'group_by':'job_id'}"/>
- <filter string="Contract Type" name="contract_type" domain="[]" context="{'group_by':'type_id'}"/>
+ <filter string="Employee Category" name="contract_type" domain="[]" context="{'group_by':'type_id'}"/>
<filter string="State" name='group_by_state' domain="[]" context="{'group_by': 'state'}"/>
</group>
</search>
@@ -222,9 +222,11 @@
<field name="model">hr.contract.type</field>
<field name="arch" type="xml">
<form string="Contract Type">
- <group col="4">
- <field colspan="4" name="name"/>
- </group>
+ <sheet>
+ <group>
+ <field name="name"/>
+ </group>
+ </sheet>
</form>
</field>
</record>
@@ -260,9 +262,17 @@
</record>
<menuitem
+ id="menu_human_resources_configuration_contract"
+ name="Contracts"
+ parent="hr.menu_human_resources_configuration"
+ sequence="3"
+ groups="base.group_no_one"/>
+
+ <menuitem
id="hr_menu_contract_type"
+ name="Types"
action="action_hr_contract_type"
- parent="hr.menu_human_resources_configuration"
+ parent="hr_contract.menu_human_resources_configuration_contract"
sequence="3"
groups="base.group_no_one"/>
diff --git a/addons/hr_expense/models/hr_expense.py b/addons/hr_expense/models/hr_expense.py
index 5e511352c64..67c7c5ebb7a 100644
--- a/addons/hr_expense/models/hr_expense.py
+++ b/addons/hr_expense/models/hr_expense.py
@@ -595,11 +595,11 @@ class HrExpenseSheet(models.Model):
def _track_subtype(self, init_values):
self.ensure_one()
if 'state' in init_values and self.state == 'approve':
- return 'hr_expense.mt_expense_approved'
+ return self.env.ref('hr_expense.mt_expense_approved')
elif 'state' in init_values and self.state == 'cancel':
- return 'hr_expense.mt_expense_refused'
+ return self.env.ref('hr_expense.mt_expense_refused')
elif 'state' in init_values and self.state == 'done':
- return 'hr_expense.mt_expense_paid'
+ return self.env.ref('hr_expense.mt_expense_paid')
return super(HrExpenseSheet, self)._track_subtype(init_values)
def _message_auto_subscribe_followers(self, updated_values, subtype_ids):
diff --git a/addons/hr_expense/views/hr_expense_views.xml b/addons/hr_expense/views/hr_expense_views.xml
index 48ca69b7a8e..d723726e633 100644
--- a/addons/hr_expense/views/hr_expense_views.xml
+++ b/addons/hr_expense/views/hr_expense_views.xml
@@ -563,7 +563,7 @@
<field name="name">hr.expense.sheet.pivot</field>
<field name="model">hr.expense.sheet</field>
<field name="arch" type="xml">
- <pivot string="Expenses Analysis" disable_linking="True">
+ <pivot string="Expenses Analysis">
<field name="employee_id" type="row"/>
<field name="accounting_date" interval="month" type="col"/>
<field name="total_amount" type="measure"/>
@@ -594,7 +594,7 @@
<field name="state"/>
<separator />
<field name="employee_id"/>
- <field name="department_id" string="Department"/>
+ <field name="department_id" operator="child_of" string="Department"/>
<field name="journal_id"/>
<filter string="My Reports" name="my_reports" domain="[('employee_id.user_id', '=', uid)]"/>
<filter string="My Team Reports" name="my_team_reports" domain="[('employee_id.parent_id.user_id', '=', uid)]" groups="hr_expense.group_hr_expense_manager" help="Expenses of Your Team Member"/>
diff --git a/addons/hr_fleet/__init__.py b/addons/hr_fleet/__init__.py
new file mode 100644
index 00000000000..c48d23bbb1d
--- /dev/null
+++ b/addons/hr_fleet/__init__.py
@@ -0,0 +1,5 @@
+# -*- encoding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import controllers
+from . import models
diff --git a/addons/hr_fleet/__manifest__.py b/addons/hr_fleet/__manifest__.py
new file mode 100644
index 00000000000..8e28eb3833e
--- /dev/null
+++ b/addons/hr_fleet/__manifest__.py
@@ -0,0 +1,15 @@
+# -*- encoding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+{
+ 'name': 'Fleet History',
+ 'version': '1.0',
+ 'category': 'Human Resources',
+ 'summary': 'Get history of driven cars for employees',
+ 'description': "",
+ 'depends': ['hr', 'fleet'],
+ 'data': [
+ 'views/employee_views.xml',
+ 'views/fleet_vehicle_views.xml',
+ ],
+ 'auto_install': True,
+}
diff --git a/addons/hr_fleet/controllers/__init__.py b/addons/hr_fleet/controllers/__init__.py
new file mode 100644
index 00000000000..5d4b25db9c0
--- /dev/null
+++ b/addons/hr_fleet/controllers/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import main
diff --git a/addons/hr_fleet/controllers/main.py b/addons/hr_fleet/controllers/main.py
new file mode 100644
index 00000000000..0a27163c3cd
--- /dev/null
+++ b/addons/hr_fleet/controllers/main.py
@@ -0,0 +1,72 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import base64
+import io
+
+from odoo import _
+from odoo.http import request, route, Controller
+
+from PyPDF2 import PdfFileReader, PdfFileWriter
+from reportlab.pdfgen import canvas
+
+
+class HrFleet(Controller):
+
+ @route(["/fleet/print_claim_report/<int:employee_id>"], type='http', auth='user')
+ def get_claim_report_user(self, employee_id, **post):
+ if not request.env.user.has_group('fleet.fleet_group_manager'):
+ return request.not_found()
+
+ employee = request.env['hr.employee'].search([('id', '=', employee_id)], limit=1)
+ partner_id = employee.user_id.partner_id.id
+ if not employee or not partner_id:
+ return request.not_found()
+
+ car_assignation_logs = request.env['fleet.vehicle.assignation.log'].search([('driver_id', '=', partner_id)])
+ doc_list = request.env['ir.attachment'].search([
+ ('res_model', '=', 'fleet.vehicle.assignation.log'),
+ ('res_id', 'in', car_assignation_logs.ids)], order='create_date')
+
+ writer = PdfFileWriter()
+
+ font = "Helvetica"
+ normal_font_size = 14
+
+ for document in doc_list:
+ car_line_doc = request.env['fleet.vehicle.assignation.log'].browse(document.res_id)
+ try:
+ reader = PdfFileReader(io.BytesIO(base64.b64decode(document.datas)), strict=False, overwriteWarnings=False)
+ except Exception:
+ continue
+
+ width = float(reader.getPage(0).mediaBox.getUpperRight_x())
+ height = float(reader.getPage(0).mediaBox.getUpperRight_y())
+
+ header = io.BytesIO()
+ can = canvas.Canvas(header)
+ can.setFont(font, normal_font_size)
+ can.setFillColorRGB(1, 0, 0)
+
+ car_name = car_line_doc.vehicle_id.display_name
+ date_start = car_line_doc.date_start
+ date_end = car_line_doc.date_end or '...'
+
+ text_to_print = _("%s (driven from: %s to %s)") % (car_name, date_start, date_end)
+ can.drawCentredString(width / 2, height - normal_font_size, text_to_print)
+ can.save()
+ header_pdf = PdfFileReader(header, overwriteWarnings=False)
+
+ for page_number in range(0, reader.getNumPages()):
+ page = reader.getPage(page_number)
+ page.mergePage(header_pdf.getPage(0))
+ writer.addPage(page)
+
+ _buffer = io.BytesIO()
+ writer.write(_buffer)
+ merged_pdf = _buffer.getvalue()
+ _buffer.close()
+
+ pdfhttpheaders = [('Content-Type', 'application/pdf'), ('Content-Length', len(merged_pdf))]
+
+ return request.make_response(merged_pdf, headers=pdfhttpheaders)
diff --git a/addons/hr_fleet/models/__init__.py b/addons/hr_fleet/models/__init__.py
new file mode 100644
index 00000000000..042da9efb91
--- /dev/null
+++ b/addons/hr_fleet/models/__init__.py
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import employee
+from . import fleet_vehicle_assignation_log
diff --git a/addons/hr_fleet/models/employee.py b/addons/hr_fleet/models/employee.py
new file mode 100644
index 00000000000..57c7441e732
--- /dev/null
+++ b/addons/hr_fleet/models/employee.py
@@ -0,0 +1,39 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+
+from odoo import fields, models
+
+class Employee(models.Model):
+ _inherit = 'hr.employee'
+
+ employee_cars_count = fields.Integer(compute="_compute_employee_cars_count", string="Cars", group="fleet_manager")
+
+ def action_open_employee_cars(self):
+ self.ensure_one()
+ cars = self.env['fleet.vehicle.assignation.log'].search([
+ ('driver_id', '=', self.user_id.partner_id.id)]).mapped('vehicle_id')
+
+ return {
+ "type": "ir.actions.act_window",
+ "res_model": "fleet.vehicle",
+ "views": [[False, "kanban"], [False, "form"], [False, "tree"]],
+ "domain": [("id", "in", cars.ids)],
+ "name": "History Employee Cars",
+ }
+
+ def _compute_employee_cars_count(self):
+ driver_ids = self.mapped('user_id.partner_id').ids
+ fleet_data = self.env['fleet.vehicle.assignation.log'].read_group(
+ domain=[('driver_id', 'in', driver_ids)], fields=['driver_id'], groupby=['driver_id'])
+ mapped_data = dict([(m['driver_id'][0], m['driver_id_count']) for m in fleet_data])
+ for employee in self:
+ employee.employee_cars_count = mapped_data.get(employee.user_id.partner_id.id, 0)
+
+ def action_get_claim_report(self):
+ self.ensure_one()
+ return {
+ 'name': 'Claim Report',
+ 'type': 'ir.actions.act_url',
+ 'url': '/fleet/print_claim_report/%(employee_id)s' % {'employee_id': self.id},
+ }
diff --git a/addons/hr_fleet/models/fleet_vehicle_assignation_log.py b/addons/hr_fleet/models/fleet_vehicle_assignation_log.py
new file mode 100644
index 00000000000..95e191b8fc8
--- /dev/null
+++ b/addons/hr_fleet/models/fleet_vehicle_assignation_log.py
@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models
+
+
+class FleetVehicleAssignationLog(models.Model):
+ _inherit = 'fleet.vehicle.assignation.log'
+
+ attachment_number = fields.Integer('Number of Attachments', compute='_compute_attachment_number')
+
+ def _compute_attachment_number(self):
+ attachment_data = self.env['ir.attachment'].read_group([
+ ('res_model', '=', 'fleet.vehicle.assignation.log'),
+ ('res_id', 'in', self.ids)], ['res_id'], ['res_id'])
+ attachment = dict((data['res_id'], data['res_id_count']) for data in attachment_data)
+ for doc in self:
+ doc.attachment_number = attachment.get(doc.id, 0)
+
+ def action_get_attachment_view(self):
+ self.ensure_one()
+ res = self.env['ir.actions.act_window'].for_xml_id('base', 'action_attachment')
+ res['domain'] = [('res_model', '=', 'fleet.vehicle.assignation.log'), ('res_id', 'in', self.ids)]
+ res['context'] = {'default_res_model': 'fleet.vehicle.assignation.log', 'default_res_id': self.id}
+ return res
diff --git a/addons/hr_fleet/views/employee_views.xml b/addons/hr_fleet/views/employee_views.xml
new file mode 100644
index 00000000000..093b7dda9e7
--- /dev/null
+++ b/addons/hr_fleet/views/employee_views.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <record id="view_employee_form" model="ir.ui.view">
+ <field name="name">hr.employee.form.inherit.hr.fleet</field>
+ <field name="model">hr.employee</field>
+ <field name="inherit_id" ref="hr.view_employee_form" />
+ <field name="arch" type="xml">
+ <xpath expr="//sheet" position="before">
+ <header>
+ <button name="action_get_claim_report" string="Claim Report"
+ type="object" groups="fleet.fleet_group_manager"
+ attrs="{'invisible': [('employee_cars_count','=', 0)]}"
+ confirm="This report will contain only PDF files. If you want all documents, please go on vehicle page. Do you want to proceed?"/>
+ </header>
+ </xpath>
+ <xpath expr="//button[@name='toggle_active']" position="before">
+ <button name="action_open_employee_cars" type="object"
+ class="oe_stat_button" icon="fa-car" groups="fleet.fleet_group_manager"
+ attrs="{'invisible': [('employee_cars_count','=', 0)]}">
+ <field name="employee_cars_count" widget="statinfo" />
+ </button>
+ </xpath>
+ </field>
+ </record>
+</odoo>
diff --git a/addons/hr_fleet/views/fleet_vehicle_views.xml b/addons/hr_fleet/views/fleet_vehicle_views.xml
new file mode 100644
index 00000000000..d1f9b41a748
--- /dev/null
+++ b/addons/hr_fleet/views/fleet_vehicle_views.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <record id="fleet_vehicle_assignation_log_view_list" model="ir.ui.view">
+ <field name="name">fleet.vehicle.assignation.log.view.tree.inherit.hr.fleet</field>
+ <field name="model">fleet.vehicle.assignation.log</field>
+ <field name="inherit_id" ref="fleet.fleet_vehicle_assignation_log_view_list" />
+ <field name="arch" type="xml">
+ <xpath expr="//field[@name='date_end']" position="after">
+ <field name="attachment_number" string=" "/>
+ <button name="action_get_attachment_view" string="View Attachments" type="object" icon="fa-paperclip"/>
+ </xpath>
+ </field>
+ </record>
+</odoo>
diff --git a/addons/hr_gamification/views/gamification_views.xml b/addons/hr_gamification/views/gamification_views.xml
index 6cccbc2a923..8eead7e4277 100644
--- a/addons/hr_gamification/views/gamification_views.xml
+++ b/addons/hr_gamification/views/gamification_views.xml
@@ -61,7 +61,7 @@
<field name="view_id" ref="gamification.challenge_form_view"/>
</record>
- <menuitem id="menu_hr_gamification" parent="hr.menu_hr_root" name="Challenges" sequence="40"/>
+ <menuitem id="menu_hr_gamification" parent="hr.menu_human_resources_configuration" name="Challenges" sequence="100"/>
<menuitem id="gamification_badge_menu_hr" parent="menu_hr_gamification" action="gamification.badge_list_action" />
<menuitem id="gamification_challenge_menu_hr" parent="menu_hr_gamification" action="challenge_list_action2" groups="hr.group_hr_user"/>
diff --git a/addons/hr_holidays/__manifest__.py b/addons/hr_holidays/__manifest__.py
index a0e95455d20..9c765ea2596 100644
--- a/addons/hr_holidays/__manifest__.py
+++ b/addons/hr_holidays/__manifest__.py
@@ -26,8 +26,8 @@ A synchronization with an internal agenda (Meetings of the CRM module) is also p
'depends': ['hr', 'calendar', 'resource'],
'data': [
'data/report_paperformat.xml',
- 'data/hr_holidays_data.xml',
'data/mail_data.xml',
+ 'data/hr_holidays_data.xml',
'data/ir_cron_data.xml',
'security/hr_holidays_security.xml',
diff --git a/addons/hr_holidays/data/hr_holidays_data.xml b/addons/hr_holidays/data/hr_holidays_data.xml
index 8f2fe272b00..56636156f7b 100644
--- a/addons/hr_holidays/data/hr_holidays_data.xml
+++ b/addons/hr_holidays/data/hr_holidays_data.xml
@@ -8,6 +8,15 @@
<field name="color_name">black</field>
<field name="validity_start" eval="time.strftime('%Y-01-01')"/>
<field name="validity_stop" eval="time.strftime('%Y-12-31')"/>
+ <field name="leave_notif_subtype_id" eval="ref('mt_leave')"/>
+ <field name="allocation_notif_subtype_id" eval="ref('mt_leave_allocation')"/>
+ </record>
+
+ <!-- Home working -->
+ <record id="holiday_status_hw" model="hr.leave.type">
+ <field name="name">Home Workings</field>
+ <field name="color_name">lightgreen</field>
+ <field name="leave_notif_subtype_id" eval="ref('mt_leave_home_working')"/>
</record>
<!-- Sick leave -->
@@ -16,6 +25,7 @@
<field name="allocation_type">no</field>
<field name="color_name">red</field>
<field name="validity_start" eval="time.strftime('%Y-01-01')"/>
+ <field name="leave_notif_subtype_id" eval="ref('mt_leave_sick')"/>
</record>
<!-- Compensatory Days -->
@@ -26,6 +36,7 @@
<field name="color_name">lavender</field>
<field name="validity_start" eval="time.strftime('%Y-01-01')"/>
<field name="validity_stop" eval="time.strftime('%Y-12-31')"/>
+ <field name="leave_notif_subtype_id" eval="ref('mt_leave')"/>
</record>
<!--Unpaid Leave -->
@@ -36,6 +47,7 @@
<field name="color_name">brown</field>
<field name="unpaid" eval="True"/>
<field name="validity_start" eval="time.strftime('%Y-01-01')"/>
+ <field name="leave_notif_subtype_id" eval="ref('mt_leave_unpaid')"/>
</record>
</data>
</odoo>
diff --git a/addons/hr_holidays/data/hr_holidays_demo.xml b/addons/hr_holidays/data/hr_holidays_demo.xml
index c28ed7bc6fe..4bc8f560b55 100644
--- a/addons/hr_holidays/data/hr_holidays_demo.xml
+++ b/addons/hr_holidays/data/hr_holidays_demo.xml
@@ -2,12 +2,11 @@
<odoo>
<data noupdate="1">
+ <!--Leave Type-->
<record id="hr_holiday_status_hl" model="hr.leave.type">
<field name="name">Overtime Compensation</field>
<field name="allocation_type">fixed_allocation</field>
<field name="color_name">blue</field>
- <field name="validity_start" eval="time.strftime('%Y-01-01')"/>
- <field name="validity_stop" eval="time.strftime('%Y-12-31')"/>
<field name="request_unit">hour</field>
</record>
@@ -18,6 +17,15 @@
<field name="validation_type">both</field>
</record>
+ <record id="hr_holiday_status_tb" model="hr.leave.type">
+ <field name="name">Team Building</field>
+ <field name="allocation_type">no</field>
+ <field name="color_name">lightblue</field>
+ <field name="validation_type">manager</field>
+ <field name="validity_start" eval="time.strftime('%Y-%m-27')"/>
+ <field name="validity_stop" eval="time.strftime('%Y-%m-27')"/>
+ </record>
+
<record id="holiday_status_cla" model="hr.leave.type">
<field name="name" eval="'Paid Time Off ' + time.strftime('%Y')"/>
<field name="allocation_type">fixed</field>
@@ -26,15 +34,37 @@
<field name="validity_stop" eval="time.strftime('%Y-12-31')"/>
</record>
- <record id="hr_holidays_employee1_allocation_cl" model="hr.leave.allocation">
- <field name="name">Legal Leaves for Peter Parker</field>
+ <!-- ++++++++++++++++++++++ All Company ++++++++++++++++++++++ -->
+
+ <record id="hr_holidays_team_building" model="hr.leave">
+ <field name="name" eval="'Team Building ' + time.strftime('%Y-%m')"/>
+ <field name="holiday_status_id" ref="hr_holiday_status_tb"/>
+ <field eval="time.strftime('%Y-%m-27 9:00:00')" name="date_from"/>
+ <field eval="time.strftime('%Y-%m-27 18:00:00')" name="date_to"/>
+ <field name="number_of_days">1</field>
+ <field name="holiday_type">company</field>
+ <field name ="mode_company_id" ref="base.main_company"/>
+ <field name ="department_id" ref="hr.dep_management"/>
+ </record>
+
+ <function model="hr.leave" name="action_approve">
+ <value model="hr.leave" search="[
+ ('state', '=', 'confirm'),
+ ('id', 'in', [ref('hr_holidays.hr_holidays_team_building')])
+ ]"/>
+ </function>
+
+ <!-- ++++++++++++++++++++++ Mitchell Admin ++++++++++++++++++++++ -->
+
+ <record id="hr_holidays_allocation_cl" model="hr.leave.allocation">
+ <field name="name">Legal Leaves for Mitchell Admin</field>
<field name="holiday_status_id" ref="holiday_status_cl"/>
<field name="number_of_days">20</field>
<field name="employee_id" ref="hr.employee_admin"/>
<field name="state">confirm</field>
</record>
- <record id="hr_holidays_employee1_int_tour" model="hr.leave.allocation">
+ <record id="hr_holidays_int_tour" model="hr.leave.allocation">
<field name="name">International Tour</field>
<field name="holiday_status_id" ref="holiday_status_comp"/>
<field name="number_of_days">7</field>
@@ -42,41 +72,42 @@
<field name="state">confirm</field>
</record>
- <record id="hr_holidays_employee1_allocation_hl" model="hr.leave.allocation">
+ <record id="hr_holidays_allocation_hl" model="hr.leave.allocation">
<field name="name">Overtime Compensation</field>
<field name="holiday_status_id" ref="hr_holiday_status_hl"/>
<field name="number_of_days">20</field>
<field name="employee_id" ref="hr.employee_admin"/>
</record>
- <record id="hr_holidays_employee1_vc" model="hr.leave.allocation">
+ <record id="hr_holidays_vc" model="hr.leave.allocation">
<field name="name">Summer Vacation</field>
<field name="holiday_status_id" ref="holiday_status_unpaid"/>
<field name="number_of_days">7</field>
<field name="employee_id" ref="hr.employee_admin"/>
</record>
- <!-- approve the first 3 leave allocations -->
+ <!-- approve leave allocations -->
<function model="hr.leave.allocation" name="action_approve">
<value model="hr.leave.allocation" search="[
('state', '=', 'confirm'),
- ('id', 'in', [ref('hr_holidays.hr_holidays_employee1_allocation_cl')])
+ ('id', 'in', [ref('hr_holidays.hr_holidays_allocation_cl')])
]"/>
</function>
<function model="hr.leave.allocation" name="action_approve">
<value model="hr.leave.allocation" search="[
('state', '=', 'confirm'),
- ('id', 'in', [ref('hr_holidays.hr_holidays_employee1_int_tour')])
+ ('id', 'in', [ref('hr_holidays.hr_holidays_int_tour')])
]"/>
</function>
<function model="hr.leave.allocation" name="action_approve">
<value model="hr.leave.allocation" search="[
('state', '=', 'confirm'),
- ('id', 'in', [ref('hr_holidays_employee1_allocation_hl')])
+ ('id', 'in', [ref('hr_holidays.hr_holidays_allocation_hl')])
]"/>
</function>
- <record id="hr_holidays_employee1_cl" model="hr.leave">
+ <!-- leave request -->
+ <record id="hr_holidays_cl" model="hr.leave">
<field name="name">Trip with Family</field>
<field name="holiday_status_id" ref="holiday_status_comp"/>
<field eval="time.strftime('%Y-%m-01')" name="date_from"/>
@@ -85,7 +116,7 @@
<field name="employee_id" ref="hr.employee_admin"/>
</record>
- <record id="hr_holidays_employee1_sl" model="hr.leave">
+ <record id="hr_holidays_sl" model="hr.leave">
<field name="name">Doctor Appointment</field>
<field name="holiday_status_id" ref="holiday_status_sl"/>
<field eval="time.strftime('%Y-%m-20')" name="date_from"/>
@@ -99,9 +130,464 @@
<function model="hr.leave" name="action_approve">
<value model="hr.leave" search="[
('state', '=', 'confirm'),
- ('id', 'in', [ref('hr_holidays.hr_holidays_employee1_sl')])
+ ('id', 'in', [ref('hr_holidays.hr_holidays_sl')])
+ ]"/>
+ </function>
+
+ <!-- ++++++++++++++++++++++ Ronnie Hart ++++++++++++++++++++++ -->
+
+ <record id="hr_holidays_allocation_cl_al" model="hr.leave.allocation">
+ <field name="name">Legal Leaves for Ronnie Hart</field>
+ <field name="holiday_status_id" ref="holiday_status_cl"/>
+ <field name="number_of_days">20</field>
+ <field name="employee_id" ref="hr.employee_al"/>
+ <field name="state">confirm</field>
+ </record>
+
+ <record id="hr_holidays_allocation_pl_al" model="hr.leave.allocation">
+ <field name="name">Parental Leaves</field>
+ <field name="holiday_status_id" ref="hr_holiday_status_dv"/>
+ <field name="number_of_days">10</field>
+ <field name="employee_id" ref="hr.employee_al"/>
+ </record>
+
+ <record id="hr_holidays_vc_al" model="hr.leave.allocation">
+ <field name="name">Summer Vacation</field>
+ <field name="holiday_status_id" ref="holiday_status_unpaid"/>
+ <field name="number_of_days">12</field>
+ <field name="employee_id" ref="hr.employee_al"/>
+ </record>
+
+ <!-- approve leave allocations -->
+ <function model="hr.leave.allocation" name="action_approve">
+ <value model="hr.leave.allocation" search="[
+ ('state', '=', 'confirm'),
+ ('id', 'in', [ref('hr_holidays.hr_holidays_allocation_cl_al')])
+ ]"/>
+ </function>
+ <function model="hr.leave.allocation" name="action_approve">
+ <value model="hr.leave.allocation" search="[
+ ('state', '=', 'confirm'),
+ ('id', 'in', [ref('hr_holidays.hr_holidays_allocation_pl_al')])
+ ]"/>
+ </function>
+ <function model="hr.leave.allocation" name="action_approve">
+ <value model="hr.leave.allocation" search="[
+ ('state', '=', 'confirm'),
+ ('id', 'in', [ref('hr_holidays.hr_holidays_vc_al')])
+ ]"/>
+ </function>
+
+ <!-- leave request -->
+ <record id="hr_holidays_cl_al" model="hr.leave">
+ <field name="name">Trip with Friends</field>
+ <field name="holiday_status_id" ref="holiday_status_cl"/>
+ <field eval="time.strftime('%Y-%m-04')" name="date_from"/>
+ <field eval="time.strftime('%Y-%m-10')" name="date_to"/>
+ <field name="number_of_days">6</field>
+ <field name="employee_id" ref="hr.employee_al"/>
+ </record>
+
+ <record id="hr_holidays_sl_al" model="hr.leave">
+ <field name="name">Dentist appointment</field>
+ <field name="holiday_status_id" ref="holiday_status_sl"/>
+ <field eval="(datetime.now()+relativedelta(months=1)).strftime('%Y-%m-17')" name="date_from"/>
+ <field eval="(datetime.now()+relativedelta(months=1)).strftime('%Y-%m-19')" name="date_to"/>
+ <field name="number_of_days">3</field>
+ <field name="employee_id" ref="hr.employee_al"/>
+ <field name="state">confirm</field>
+ </record>
+
+ <!-- approve the leave request -->
+ <function model="hr.leave" name="action_approve">
+ <value model="hr.leave" search="[
+ ('state', '=', 'confirm'),
+ ('id', 'in', [ref('hr_holidays.hr_holidays_sl_al')])
+ ]"/>
+ </function>
+
+ <function model="hr.leave" name="action_approve">
+ <value model="hr.leave" search="[
+ ('state', '=', 'confirm'),
+ ('id', 'in', [ref('hr_holidays.hr_holidays_cl_al')])
+ ]"/>
+ </function>
+
+ <!-- ++++++++++++++++++++++ Anita Oliver ++++++++++++++++++++++ -->
+
+ <record id="hr_holidays_allocation_cl_mit" model="hr.leave.allocation">
+ <field name="name">Legal Leaves for Anita Oliver</field>
+ <field name="holiday_status_id" ref="holiday_status_cl"/>
+ <field name="number_of_days">20</field>
+ <field name="employee_id" ref="hr.employee_mit"/>
+ <field name="state">confirm</field>
+ </record>
+
+ <record id="hr_holidays_allocation_hl_mit" model="hr.leave.allocation">
+ <field name="name">Overtime Compensation</field>
+ <field name="holiday_status_id" ref="hr_holiday_status_hl"/>
+ <field name="number_of_days">5</field>
+ <field name="employee_id" ref="hr.employee_mit"/>
+ </record>
+
+ <record id="hr_holidays_vc_mit" model="hr.leave.allocation">
+ <field name="name">Summer Vacation</field>
+ <field name="holiday_status_id" ref="holiday_status_unpaid"/>
+ <field name="number_of_days">7</field>
+ <field name="employee_id" ref="hr.employee_mit"/>
+ </record>
+
+ <!-- approve leave allocations -->
+ <function model="hr.leave.allocation" name="action_approve">
+ <value model="hr.leave.allocation" search="[
+ ('state', '=', 'confirm'),
+ ('id', 'in', [ref('hr_holidays.hr_holidays_allocation_cl_mit')])
+ ]"/>
+ </function>
+ <function model="hr.leave.allocation" name="action_approve">
+ <value model="hr.leave.allocation" search="[
+ ('state', '=', 'confirm'),
+ ('id', 'in', [ref('hr_holidays.hr_holidays_allocation_hl_mit')])
+ ]"/>
+ </function>
+
+ <!-- leave request -->
+ <record id="hr_holidays_cl_mit" model="hr.leave">
+ <field name="name">Trip to Paris</field>
+ <field name="holiday_status_id" ref="holiday_status_cl"/>
+ <field eval="time.strftime('%Y-%m-18')" name="date_from"/>
+ <field eval="time.strftime('%Y-%m-24')" name="date_to"/>
+ <field name="number_of_days">7</field>
+ <field name="employee_id" ref="hr.employee_mit"/>
+ </record>
+
+ <record id="hr_holidays_cl_mit_2" model="hr.leave">
+ <field name="name">Trip</field>
+ <field name="holiday_status_id" ref="holiday_status_cl"/>
+ <field eval="time.strftime('%Y-%m-5')" name="date_from"/>
+ <field eval="time.strftime('%Y-%m-7')" name="date_to"/>
+ <field name="number_of_days">3</field>
+ <field name="employee_id" ref="hr.employee_mit"/>
+ </record>
+
+ <record id="hr_holidays_sl_mit" model="hr.leave">
+ <field name="name">Sleeping day</field>
+ <field name="holiday_status_id" ref="hr_holiday_status_hl"/>
+ <field eval="time.strftime('%Y-%m-1')" name="date_from"/>
+ <field eval="time.strftime('%Y-%m-3')" name="date_to"/>
+ <field name="number_of_days">3</field>
+ <field name="employee_id" ref="hr.employee_mit"/>
+ <field name="state">confirm</field>
+ </record>
+
+ <!-- approve the leave request -->
+ <function model="hr.leave" name="action_approve">
+ <value model="hr.leave" search="[
+ ('state', '=', 'confirm'),
+ ('id', 'in', [ref('hr_holidays.hr_holidays_sl_mit')])
+ ]"/>
+ </function>
+ <function model="hr.leave" name="action_approve">
+ <value model="hr.leave" search="[
+ ('state', '=', 'confirm'),
+ ('id', 'in', [ref('hr_holidays.hr_holidays_cl_mit')])
+ ]"/>
+ </function>
+
+ <!-- ++++++++++++++++++++++ Gilles Gravie ++++++++++++++++++++++ -->
+
+ <record id="hr_holidays_allocation_cl_qdp" model="hr.leave.allocation">
+ <field name="name">Legal Leaves for Gilles Gravie</field>
+ <field name="holiday_status_id" ref="holiday_status_cl"/>
+ <field name="number_of_days">20</field>
+ <field name="employee_id" ref="hr.employee_qdp"/>
+ <field name="state">confirm</field>
+ </record>
+
+ <record id="hr_holidays_allocation_hl_qdp" model="hr.leave.allocation">
+ <field name="name">Overtime Compensation</field>
+ <field name="holiday_status_id" ref="hr_holiday_status_hl"/>
+ <field name="number_of_days">5</field>
+ <field name="employee_id" ref="hr.employee_qdp"/>
+ </record>
+
+ <record id="hr_holidays_vc_qdp" model="hr.leave.allocation">
+ <field name="name">Summer Vacation</field>
+ <field name="holiday_status_id" ref="holiday_status_unpaid"/>
+ <field name="number_of_days">7</field>
+ <field name="employee_id" ref="hr.employee_qdp"/>
+ </record>
+
+ <!-- approve leave allocations -->
+ <function model="hr.leave.allocation" name="action_approve">
+ <value model="hr.leave.allocation" search="[
+ ('state', '=', 'confirm'),
+ ('id', 'in', [ref('hr_holidays.hr_holidays_allocation_cl_qdp')])
+ ]"/>
+ </function>
+ <function model="hr.leave.allocation" name="action_approve">
+ <value model="hr.leave.allocation" search="[
+ ('state', '=', 'confirm'),
+ ('id', 'in', [ref('hr_holidays.hr_holidays_vc_qdp')])
+ ]"/>
+ </function>
+
+ <!-- leave request -->
+ <record id="hr_holidays_cl_qdp" model="hr.leave">
+ <field name="name">Sick day</field>
+ <field name="holiday_status_id" ref="holiday_status_sl"/>
+ <field eval="(datetime.now()+relativedelta(months=1)).strftime('%Y-%m-3 1:0:0')" name="date_from"/>
+ <field eval="(datetime.now()+relativedelta(months=1)).strftime('%Y-%m-5 23:0:0')" name="date_to"/>
+ <field name="number_of_days">3</field>
+ <field name="employee_id" ref="hr.employee_qdp"/>
+ <field name="state">confirm</field>
+ </record>
+
+ <record id="hr_holidays_sl_qdp" model="hr.leave">
+ <field name="name">Sick day</field>
+ <field name="holiday_status_id" ref="holiday_status_sl"/>
+ <field eval="time.strftime('%Y-%m-1 1:0:0')" name="date_from"/>
+ <field eval="time.strftime('%Y-%m-1 23:0:0')" name="date_to"/>
+ <field name="number_of_days">1</field>
+ <field name="employee_id" ref="hr.employee_qdp"/>
+ <field name="state">confirm</field>
+ </record>
+
+ <!-- approve the leave request -->
+ <function model="hr.leave" name="action_approve">
+ <value model="hr.leave" search="[
+ ('state', '=', 'confirm'),
+ ('id', 'in', [ref('hr_holidays.hr_holidays_sl_qdp')])
+ ]"/>
+ </function>
+ <function model="hr.leave" name="action_approve">
+ <value model="hr.leave" search="[
+ ('state', '=', 'confirm'),
+ ('id', 'in', [ref('hr_holidays.hr_holidays_cl_qdp')])
+ ]"/>
+ </function>
+
+ <!-- ++++++++++++++++++++++ Audrey Peterson ++++++++++++++++++++++ -->
+
+ <record id="hr_holidays_allocation_cl_fpi" model="hr.leave.allocation">
+ <field name="name">Legal Leaves for Audrey Peterson</field>
+ <field name="holiday_status_id" ref="holiday_status_cl"/>
+ <field name="number_of_days">20</field>
+ <field name="employee_id" ref="hr.employee_fpi"/>
+ <field name="state">confirm</field>
+ </record>
+
+ <record id="hr_holidays_int_tour_fpi" model="hr.leave.allocation">
+ <field name="name">International Tour</field>
+ <field name="holiday_status_id" ref="holiday_status_hw"/>
+ <field name="number_of_days">8</field>
+ <field name="employee_id" ref="hr.employee_fpi"/>
+ <field name="state">confirm</field>
+ </record>
+
+ <record id="hr_holidays_allocation_hl_fpi" model="hr.leave.allocation">
+ <field name="name">Overtime Compensation</field>
+ <field name="holiday_status_id" ref="hr_holiday_status_hl"/>
+ <field name="number_of_days">10</field>
+ <field name="employee_id" ref="hr.employee_fpi"/>
+ </record>
+
+ <record id="hr_holidays_vc_fpi" model="hr.leave.allocation">
+ <field name="name">Summer Vacation</field>
+ <field name="holiday_status_id" ref="holiday_status_unpaid"/>
+ <field name="number_of_days">7</field>
+ <field name="employee_id" ref="hr.employee_fpi"/>
+ </record>
+
+ <!-- approve leave allocations -->
+ <function model="hr.leave.allocation" name="action_approve">
+ <value model="hr.leave.allocation" search="[
+ ('state', '=', 'confirm'),
+ ('id', 'in', [ref('hr_holidays.hr_holidays_allocation_cl_fpi')])
+ ]"/>
+ </function>
+ <function model="hr.leave.allocation" name="action_approve">
+ <value model="hr.leave.allocation" search="[
+ ('state', '=', 'confirm'),
+ ('id', 'in', [ref('hr_holidays.hr_holidays_int_tour_fpi')])
+ ]"/>
+ </function>
+ <function model="hr.leave.allocation" name="action_approve">
+ <value model="hr.leave.allocation" search="[
+ ('state', '=', 'confirm'),
+ ('id', 'in', [ref('hr_holidays.hr_holidays_allocation_hl_fpi')])
+ ]"/>
+ </function>
+
+ <!-- leave request -->
+ <record id="hr_holidays_cl_fpi" model="hr.leave">
+ <field name="name">Trip to NY</field>
+ <field name="holiday_status_id" ref="hr_holiday_status_hl"/>
+ <field eval="time.strftime('%Y-%m-3 1:0:0')" name="date_from"/>
+ <field eval="time.strftime('%Y-%m-5 23:0:0')" name="date_to"/>
+ <field name="number_of_days">3</field>
+ <field name="employee_id" ref="hr.employee_fpi"/>
+ <field name="state">confirm</field>
+ </record>
+ <record id="hr_holidays_cl_fpi_2" model="hr.leave">
+ <field name="name">Trip to Barcelona</field>
+ <field name="holiday_status_id" ref="hr_holiday_status_hl"/>
+ <field eval="time.strftime('%Y-%m-7 1:0:0')" name="date_from"/>
+ <field eval="time.strftime('%Y-%m-9 23:0:0')" name="date_to"/>
+ <field name="number_of_days">3</field>
+ <field name="employee_id" ref="hr.employee_fpi"/>
+ <field name="state">confirm</field>
+ </record>
+
+ <record id="hr_holidays_sl_fpi" model="hr.leave">
+ <field name="name">Home working</field>
+ <field name="holiday_status_id" ref="holiday_status_hw"/>
+ <field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-10 1:0:0')" name="date_from"/>
+ <field eval="(datetime.now()+relativedelta(months=-1)).strftime('%Y-%m-17 23:0:0')" name="date_to"/>
+ <field name="number_of_days">8</field>
+ <field name="employee_id" ref="hr.employee_fpi"/>
+ </record>
+
+ <!-- approve the leave request -->
+ <function model="hr.leave" name="action_approve">
+ <value model="hr.leave" search="[
+ ('state', '=', 'confirm'),
+ ('id', 'in', [ref('hr_holidays.hr_holidays_sl_fpi')])
]"/>
</function>
+ <function model="hr.leave" name="action_approve">
+ <value model="hr.leave" search="[
+ ('state', '=', 'confirm'),
+ ('id', 'in', [ref('hr_holidays.hr_holidays_cl_fpi')])
+ ]"/>
+ </function>
+
+ <!-- refuse the leave request -->
+ <function model="hr.leave" name="action_refuse">
+ <value model="hr.leave" search="[
+ ('state', '=', 'confirm'),
+ ('id', 'in', [ref('hr_holidays.hr_holidays_cl_fpi_2')])
+ ]"/>
+ </function>
+
+ <!-- ++++++++++++++++++++++ Olivia ++++++++++++++++++++++ -->
+
+ <record id="hr_holidays_allocation_cl_vad" model="hr.leave.allocation">
+ <field name="name">Legal Leaves for Olivia</field>
+ <field name="holiday_status_id" ref="holiday_status_cl"/>
+ <field name="number_of_days">20</field>
+ <field name="employee_id" ref="hr.employee_niv"/>
+ <field name="state">confirm</field>
+ </record>
+
+ <record id="hr_holidays_allocation_hl_vad" model="hr.leave.allocation">
+ <field name="name">Overtime Compensation</field>
+ <field name="holiday_status_id" ref="hr_holiday_status_hl"/>
+ <field name="number_of_days">10</field>
+ <field name="employee_id" ref="hr.employee_niv"/>
+ </record>
+
+ <record id="hr_holidays_vc_vad" model="hr.leave.allocation">
+ <field name="name">Summer Vacation</field>
+ <field name="holiday_status_id" ref="holiday_status_unpaid"/>
+ <field name="number_of_days">5</field>
+ <field name="employee_id" ref="hr.employee_niv"/>
+ </record>
+
+ <!-- approve leave allocations -->
+ <function model="hr.leave.allocation" name="action_approve">
+ <value model="hr.leave.allocation" search="[
+ ('state', '=', 'confirm'),
+ ('id', 'in', [ref('hr_holidays.hr_holidays_allocation_cl_vad')])
+ ]"/>
+ </function>
+ <function model="hr.leave.allocation" name="action_approve">
+ <value model="hr.leave.allocation" search="[
+ ('state', '=', 'confirm'),
+ ('id', 'in', [ref('hr_holidays.hr_holidays_allocation_hl_vad')])
+ ]"/>
+ </function>
+ <function model="hr.leave.allocation" name="action_approve">
+ <value model="hr.leave.allocation" search="[
+ ('state', '=', 'confirm'),
+ ('id', 'in', [ref('hr_holidays.hr_holidays_vc_vad')])
+ ]"/>
+ </function>
+
+
+ <record id="hr_holidays_cl_vad" model="hr.leave">
+ <field name="name">Trip to London</field>
+ <field name="holiday_status_id" ref="holiday_status_cl"/>
+ <field eval="time.strftime('%Y-%m-09')" name="date_from"/>
+ <field eval="time.strftime('%Y-%m-16')" name="date_to"/>
+ <field name="number_of_days">8</field>
+ <field name="employee_id" ref="hr.employee_niv"/>
+ <field name="state">confirm</field>
+ </record>
+
+ <record id="hr_holidays_sl_vad" model="hr.leave">
+ <field name="name">Doctor Appointment</field>
+ <field name="holiday_status_id" ref="holiday_status_sl"/>
+ <field eval="time.strftime('%Y-%m-25 1:0:0')" name="date_from"/>
+ <field eval="time.strftime('%Y-%m-26 23:0:0')" name="date_to"/>
+ <field name="number_of_days">2</field>
+ <field name="employee_id" ref="hr.employee_niv"/>
+ <field name="state">confirm</field>
+ </record>
+
+
+ <!-- approve the leave request -->
+ <function model="hr.leave" name="action_approve">
+ <value model="hr.leave" search="[
+ ('state', '=', 'confirm'),
+ ('id', 'in', [ref('hr_holidays.hr_holidays_sl_vad')])
+ ]"/>
+ </function>
+
+ <!-- ++++++++++++++++++++++ Kim ++++++++++++++++++++++ -->
+
+ <record id="hr_holidays_allocation_cl_kim" model="hr.leave.allocation">
+ <field name="name">Legal Leaves for Kim</field>
+ <field name="holiday_status_id" ref="holiday_status_cl"/>
+ <field name="number_of_days">20</field>
+ <field name="employee_id" ref="hr.employee_jve"/>
+ <field name="state">confirm</field>
+ </record>
+
+ <record id="hr_holidays_allocation_hl_kim" model="hr.leave.allocation">
+ <field name="name">Overtime Compensation</field>
+ <field name="holiday_status_id" ref="hr_holiday_status_hl"/>
+ <field name="number_of_days">10</field>
+ <field name="employee_id" ref="hr.employee_jve"/>
+ </record>
+
+ <record id="hr_holidays_vc_kim" model="hr.leave.allocation">
+ <field name="name">Summer Vacation</field>
+ <field name="holiday_status_id" ref="holiday_status_unpaid"/>
+ <field name="number_of_days">5</field>
+ <field name="employee_id" ref="hr.employee_jve"/>
+ </record>
+
+ <!-- leave request -->
+ <record id="hr_holidays_sl_kim" model="hr.leave">
+ <field name="name">Dentist appointment</field>
+ <field name="holiday_status_id" ref="holiday_status_sl"/>
+ <field eval="(datetime.now()+relativedelta(months=1)).strftime('%Y-%m-1 1:0:0')" name="date_from"/>
+ <field eval="(datetime.now()+relativedelta(months=1)).strftime('%Y-%m-1 23:0:0')" name="date_to"/>
+ <field name="number_of_days">1</field>
+ <field name="employee_id" ref="hr.employee_jve"/>
+ <field name="state">confirm</field>
+ </record>
+
+ <record id="hr_holidays_sl_kim_2" model="hr.leave">
+ <field name="name">Second dentist appointment</field>
+ <field name="holiday_status_id" ref="holiday_status_sl"/>
+ <field eval="(datetime.now()+relativedelta(months=4)).strftime('%Y-%m-3 1:0:0')" name="date_from"/>
+ <field eval="(datetime.now()+relativedelta(months=4)).strftime('%Y-%m-3 23:0:0')" name="date_to"/>
+ <field name="number_of_days">1</field>
+ <field name="employee_id" ref="hr.employee_jve"/>
+ <field name="state">confirm</field>
+ </record>
</data>
</odoo>
diff --git a/addons/hr_holidays/data/mail_data.xml b/addons/hr_holidays/data/mail_data.xml
index 2dc75bcb8c8..9b0a62f06c4 100644
--- a/addons/hr_holidays/data/mail_data.xml
+++ b/addons/hr_holidays/data/mail_data.xml
@@ -26,63 +26,32 @@
</record>
<!-- Holidays-related subtypes for messaging / Chatter -->
- <record id="mt_leave_approved" model="mail.message.subtype">
- <field name="name">Approved</field>
+ <record id="mt_leave" model="mail.message.subtype">
+ <field name="name">Leave</field>
<field name="res_model">hr.leave</field>
- <field name="description">Request approved</field>
+ <field name="description">Leave Request</field>
</record>
- <record id="mt_leave_refused" model="mail.message.subtype">
- <field name="name">Refused</field>
+ <record id="mt_leave_home_working" model="mail.message.subtype">
+ <field name="name">Home Working</field>
<field name="res_model">hr.leave</field>
- <field name="default" eval="False"/>
- <field name="description">Request refused</field>
+ <field name="description">Home Working</field>
</record>
-
- <!-- Allocation-related subtypes for messaging / Chatter -->
- <record id="mt_leave_allocation_approved" model="mail.message.subtype">
- <field name="name">Approved</field>
- <field name="res_model">hr.leave.allocation</field>
- <field name="description">Request approved</field>
+ <record id="mt_leave_sick" model="mail.message.subtype">
+ <field name="name">Sick Leave</field>
+ <field name="res_model">hr.leave</field>
+ <field name="description">Sick Leave</field>
</record>
- <record id="mt_leave_allocation_refused" model="mail.message.subtype">
- <field name="name">Refused</field>
- <field name="res_model">hr.leave.allocation</field>
- <field name="default" eval="False"/>
- <field name="description">Request refused</field>
+ <record id="mt_leave_unpaid" model="mail.message.subtype">
+ <field name="name">Unpaid Leave</field>
+ <field name="res_model">hr.leave</field>
+ <field name="description">Unpaid Leave</field>
</record>
- <!-- Department related subtypes for messaging / Chatter -->
- <record id="mt_department_leave_approved" model="mail.message.subtype">
- <field name="name">Leaves Approved</field>
- <field name="res_model">hr.department</field>
- <field name="default" eval="False"/>
- <field name="parent_id" eval="ref('mt_leave_approved')"/>
- <field name="relation_field">department_id</field>
- <field name="sequence" eval="8"/>
- </record>
- <record id="mt_department_leave_refused" model="mail.message.subtype">
- <field name="name">Leaves Refused</field>
- <field name="res_model">hr.department</field>
- <field name="default" eval="False"/>
- <field name="parent_id" eval="ref('mt_leave_refused')"/>
- <field name="relation_field">department_id</field>
- <field name="sequence" eval="9"/>
- </record>
- <record id="mt_department_leave_allocation_approved" model="mail.message.subtype">
- <field name="name">Allocation Approved</field>
- <field name="res_model">hr.department</field>
- <field name="default" eval="False"/>
- <field name="parent_id" eval="ref('mt_leave_allocation_approved')"/>
- <field name="relation_field">department_id</field>
- <field name="sequence" eval="8"/>
- </record>
- <record id="mt_department_leave_allocation_refused" model="mail.message.subtype">
- <field name="name">Allocation Refused</field>
- <field name="res_model">hr.department</field>
- <field name="default" eval="False"/>
- <field name="parent_id" eval="ref('mt_leave_allocation_refused')"/>
- <field name="relation_field">department_id</field>
- <field name="sequence" eval="9"/>
+ <!-- Allocation-related subtypes for messaging / Chatter -->
+ <record id="mt_leave_allocation" model="mail.message.subtype">
+ <field name="name">Allocation</field>
+ <field name="res_model">hr.leave.allocation</field>
+ <field name="description">Allocation Request</field>
</record>
</data>
</odoo> \ No newline at end of file
diff --git a/addons/hr_holidays/models/__init__.py b/addons/hr_holidays/models/__init__.py
index 621aeccd08b..d391276412b 100644
--- a/addons/hr_holidays/models/__init__.py
+++ b/addons/hr_holidays/models/__init__.py
@@ -6,3 +6,4 @@ from . import hr
from . import hr_leave
from . import hr_leave_allocation
from . import hr_leave_type
+from . import mail_message_subtype \ No newline at end of file
diff --git a/addons/hr_holidays/models/hr.py b/addons/hr_holidays/models/hr.py
index f879a0c0ef4..05b35373309 100644
--- a/addons/hr_holidays/models/hr.py
+++ b/addons/hr_holidays/models/hr.py
@@ -63,6 +63,10 @@ class Department(models.Model):
class Employee(models.Model):
_inherit = "hr.employee"
+ leave_manager_id = fields.Many2one(
+ 'res.users', string='Leave Responsible',
+ domain=lambda self: [('groups_id', 'in', self.env.ref('hr_holidays.group_hr_holidays_team_leader').id)],
+ help="User responsible of leaves approval. Should be Team Leader or Department Manager.")
remaining_leaves = fields.Float(
compute='_compute_remaining_leaves', string='Remaining Legal Leaves',
help='Total number of legal leaves allocated to this employee, change this value to create allocation/leave request. '
@@ -190,13 +194,14 @@ class Employee(models.Model):
def write(self, values):
res = super(Employee, self).write(values)
+ today_date = fields.Datetime.now()
if 'parent_id' in values or 'department_id' in values:
hr_vals = {}
if values.get('parent_id') is not None:
hr_vals['manager_id'] = values['parent_id']
if values.get('department_id') is not None:
hr_vals['department_id'] = values['department_id']
- holidays = self.env['hr.leave'].search([('state', 'in', ['draft', 'confirm']), ('employee_id', 'in', self.ids)])
+ holidays = self.env['hr.leave'].search(['|',('state', 'in', ['draft', 'confirm']),('date_from', '>', today_date), ('employee_id', 'in', self.ids)])
holidays.write(hr_vals)
allocations = self.env['hr.leave.allocation'].search([('state', 'in', ['draft', 'confirm']), ('employee_id', 'in', self.ids)])
allocations.write(hr_vals)
diff --git a/addons/hr_holidays/models/hr_leave.py b/addons/hr_holidays/models/hr_leave.py
index 4654c2da2cd..f4086bb2006 100644
--- a/addons/hr_holidays/models/hr_leave.py
+++ b/addons/hr_holidays/models/hr_leave.py
@@ -31,6 +31,9 @@ class HolidaysRequest(models.Model):
- can discuss on its leave requests;
- can reset only its own leaves;
- cannot validate any leaves;
+ - a Team Leader
+ - has same rights as a regular employee
+ - but is able to approve employees' leaves who are in his/her team (see Employee.leave_manager_id in hr_holidays/hr.py)
- an Officer
- can see all leaves;
- can validate "HR" single validation leaves from people if
@@ -290,6 +293,7 @@ class HolidaysRequest(models.Model):
tz = self.env.user.tz if self.env.user.tz and not self.request_unit_custom else 'UTC' # custom -> already in UTC
self.date_from = timezone(tz).localize(datetime.combine(self.request_date_from, hour_from)).astimezone(UTC).replace(tzinfo=None)
self.date_to = timezone(tz).localize(datetime.combine(self.request_date_to, hour_to)).astimezone(UTC).replace(tzinfo=None)
+ self._onchange_leave_dates()
@api.onchange('request_unit_half')
def _onchange_request_unit_half(self):
@@ -471,8 +475,10 @@ class HolidaysRequest(models.Model):
values.update({'department_id': self.env['hr.employee'].browse(employee_id).department_id.id})
holiday = super(HolidaysRequest, self.with_context(mail_create_nolog=True, mail_create_nosubscribe=True)).create(values)
holiday.add_follower(employee_id)
- if 'employee_id' in values:
+ if employee_id:
holiday._onchange_employee_id()
+ if 'number_of_days' not in values and ('date_from' in values or 'date_to' in values):
+ holiday._onchange_leave_dates()
holiday.activity_update()
return holiday
@@ -500,9 +506,12 @@ class HolidaysRequest(models.Model):
if values.get('state'):
self._check_approval_update(values['state'])
result = super(HolidaysRequest, self).write(values)
- self.add_follower(employee_id)
- if 'employee_id' in values:
- self._onchange_employee_id()
+ for holiday in self:
+ if employee_id:
+ holiday.add_follower(employee_id)
+ holiday._onchange_employee_id()
+ if 'number_of_days' not in values and ('date_from' in values or 'date_to' in values):
+ holiday._onchange_leave_dates()
return result
@api.multi
@@ -686,7 +695,7 @@ class HolidaysRequest(models.Model):
def _check_approval_update(self, state):
""" Check if target state is achievable. """
current_employee = self.env['hr.employee'].search([('user_id', '=', self.env.uid)], limit=1)
- is_officer = self.env.user.has_group('hr_holidays.group_hr_holidays_user')
+ is_team_leader = self.env.user.has_group('hr_holidays.group_hr_holidays_team_leader')
is_manager = self.env.user.has_group('hr_holidays.group_hr_holidays_manager')
for holiday in self:
val_type = holiday.holiday_status_id.validation_type
@@ -698,10 +707,10 @@ class HolidaysRequest(models.Model):
raise UserError(_('Only a Leave Manager can reset other people leaves.'))
continue
- if not is_officer:
- raise UserError(_('Only a Leave Officer or Manager can approve or refuse leave requests.'))
+ if not is_team_leader:
+ raise UserError(_('Only a Team Leader, Leave Officer or Manager can approve or refuse leave requests.'))
- if is_officer:
+ if is_team_leader:
# use ir.rule based first access check: department, members, ... (see security.xml)
holiday.check_access_rule('write')
@@ -722,8 +731,8 @@ class HolidaysRequest(models.Model):
# ------------------------------------------------------------
def _get_responsible_for_approval(self):
- if self.state == 'confirm' and self.manager_id.user_id:
- return self.manager_id.user_id
+ if self.state == 'confirm' and self.employee_id.leave_manager_id:
+ return self.employee_id.leave_manager_id
elif self.state == 'confirm' and self.employee_id.parent_id.user_id:
return self.employee_id.parent_id.user_id
elif self.department_id.manager_id.user_id:
@@ -760,9 +769,8 @@ class HolidaysRequest(models.Model):
@api.multi
def _track_subtype(self, init_values):
if 'state' in init_values and self.state == 'validate':
- return 'hr_holidays.mt_leave_approved'
- elif 'state' in init_values and self.state == 'refuse':
- return 'hr_holidays.mt_leave_refused'
+ leave_notif_subtype = self.holiday_status_id.leave_notif_subtype_id
+ return leave_notif_subtype or self.env.ref('hr_holidays.mt_leave')
return super(HolidaysRequest, self)._track_subtype(init_values)
@api.multi
diff --git a/addons/hr_holidays/models/hr_leave_allocation.py b/addons/hr_holidays/models/hr_leave_allocation.py
index a6081d2d566..0b39ce37708 100644
--- a/addons/hr_holidays/models/hr_leave_allocation.py
+++ b/addons/hr_holidays/models/hr_leave_allocation.py
@@ -59,6 +59,7 @@ class HolidaysAllocation(models.Model):
employee_id = fields.Many2one(
'hr.employee', string='Employee', index=True, readonly=True,
states={'draft': [('readonly', False)], 'confirm': [('readonly', False)]}, default=_default_employee, track_visibility='onchange')
+ manager_id = fields.Many2one('hr.employee', string='Manager', readonly=True)
notes = fields.Text('Reasons', readonly=True, states={'draft': [('readonly', False)], 'confirm': [('readonly', False)]})
# duration
number_of_days = fields.Float(
@@ -257,6 +258,7 @@ class HolidaysAllocation(models.Model):
@api.onchange('employee_id')
def _onchange_employee(self):
+ self.manager_id = self.employee_id and self.employee_id.parent_id
if self.holiday_type == 'employee':
self.department_id = self.employee_id.department_id
@@ -336,6 +338,8 @@ class HolidaysAllocation(models.Model):
values.update({'department_id': self.env['hr.employee'].browse(employee_id).department_id.id})
holiday = super(HolidaysAllocation, self.with_context(mail_create_nolog=True, mail_create_nosubscribe=True)).create(values)
holiday.add_follower(employee_id)
+ if 'employee_id' in values:
+ holiday._onchange_employee()
holiday.activity_update()
return holiday
@@ -346,6 +350,8 @@ class HolidaysAllocation(models.Model):
self._check_approval_update(values['state'])
result = super(HolidaysAllocation, self).write(values)
self.add_follower(employee_id)
+ if 'employee_id' in values:
+ self._onchange_employee()
return result
@api.multi
@@ -550,9 +556,8 @@ class HolidaysAllocation(models.Model):
@api.multi
def _track_subtype(self, init_values):
if 'state' in init_values and self.state == 'validate':
- return 'hr_holidays.mt_leave_allocation_approved'
- elif 'state' in init_values and self.state == 'refuse':
- return 'hr_holidays.mt_leave_allocation_refused'
+ allocation_notif_subtype_id = self.holiday_status_id.allocation_notif_subtype_id
+ return allocation_notif_subtype_id or self.env.ref('hr_holidays.mt_leave_allocation')
return super(HolidaysAllocation, self)._track_subtype(init_values)
@api.multi
diff --git a/addons/hr_holidays/models/hr_leave_type.py b/addons/hr_holidays/models/hr_leave_type.py
index 828df4a5586..802039a388c 100644
--- a/addons/hr_holidays/models/hr_leave_type.py
+++ b/addons/hr_holidays/models/hr_leave_type.py
@@ -20,6 +20,7 @@ class HolidaysType(models.Model):
_order = "sequence, id"
name = fields.Char('Leave Type', required=True, translate=True)
+ code = fields.Char('Code')
sequence = fields.Integer(default=100,
help='The type with the smallest sequence is the default value in leave request')
categ_id = fields.Many2one(
@@ -83,6 +84,8 @@ class HolidaysType(models.Model):
('day', 'Day'), ('hour', 'Hours')],
default='day', string='Take Leaves in', required=True)
unpaid = fields.Boolean('Is Unpaid', default=False)
+ leave_notif_subtype_id = fields.Many2one('mail.message.subtype', string='Leave Notification Subtype')
+ allocation_notif_subtype_id = fields.Many2one('mail.message.subtype', string='Allocation Notification Subtype')
@api.multi
@api.constrains('validity_start', 'validity_stop')
diff --git a/addons/hr_holidays/models/mail_message_subtype.py b/addons/hr_holidays/models/mail_message_subtype.py
new file mode 100644
index 00000000000..78d0c394ad5
--- /dev/null
+++ b/addons/hr_holidays/models/mail_message_subtype.py
@@ -0,0 +1,50 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import logging
+
+from odoo import api, models
+
+_logger = logging.getLogger(__name__)
+
+
+class MailMessageSubtype(models.Model):
+ _inherit = 'mail.message.subtype'
+
+ def _get_department_subtype(self):
+ return self.search([
+ ('res_model', '=', 'hr.department'),
+ ('parent_id', '=', self.id)])
+
+ def _update_department_subtype(self):
+ for subtype in self:
+ department_subtype = subtype._get_department_subtype()
+ if department_subtype:
+ department_subtype.write({
+ 'name': subtype.name,
+ 'default': subtype.default,
+ })
+ else:
+ department_subtype = self.create({
+ 'name': subtype.name,
+ 'res_model': 'hr.department',
+ 'default': subtype.default or False,
+ 'parent_id': subtype.id,
+ 'relation_field': 'department_id',
+ })
+ return department_subtype
+
+ @api.model
+ def create(self, vals):
+ result = super(MailMessageSubtype, self).create(vals)
+ if result.res_model in ['hr.leave', 'hr.leave.allocation']:
+ result._update_department_subtype()
+ return result
+
+ @api.multi
+ def write(self, vals):
+ result = super(MailMessageSubtype, self).write(vals)
+ self.filtered(
+ lambda subtype: subtype.res_model in ['hr.leave', 'hr.leave.allocation']
+ )._update_department_subtype()
+ return result
diff --git a/addons/hr_holidays/report/hr_leave_report.py b/addons/hr_holidays/report/hr_leave_report.py
index cbef7ec4b20..299d3705c53 100644
--- a/addons/hr_holidays/report/hr_leave_report.py
+++ b/addons/hr_holidays/report/hr_leave_report.py
@@ -13,10 +13,10 @@ class LeaveReport(models.Model):
employee_id = fields.Many2one('hr.employee', string="Employee", readonly=True)
name = fields.Char('Description', readonly=True)
number_of_days = fields.Float('Number of Days', readonly=True)
- type = fields.Selection([
+ leave_type = fields.Selection([
('allocation', 'Allocation Request'),
('request', 'Leave Request')
- ], string='Request Type', readonly=True)
+ ], string='Request Type', readonly=True, oldname='type')
department_id = fields.Many2one('hr.department', string='Department', readonly=True)
category_id = fields.Many2one('hr.employee.category', string='Employee Tag', readonly=True)
holiday_status_id = fields.Many2one("hr.leave.type", string="Leave Type", readonly=True)
@@ -43,7 +43,7 @@ class LeaveReport(models.Model):
CREATE or REPLACE view hr_leave_report as (
SELECT row_number() over(ORDER BY leaves.employee_id) as id,
leaves.employee_id as employee_id, leaves.name as name,
- leaves.number_of_days as number_of_days, leaves.type as type,
+ leaves.number_of_days as number_of_days, leaves.leave_type as leave_type,
leaves.category_id as category_id, leaves.department_id as department_id,
leaves.holiday_status_id as holiday_status_id, leaves.state as state,
leaves.holiday_type as holiday_type, leaves.date_from as date_from,
@@ -60,7 +60,7 @@ class LeaveReport(models.Model):
null as date_from,
null as date_to,
FALSE as payslip_status,
- 'allocation' as type
+ 'allocation' as leave_type
from hr_leave_allocation as allocation
union all select
request.employee_id as employee_id,
@@ -74,7 +74,7 @@ class LeaveReport(models.Model):
request.date_from as date_from,
request.date_to as date_to,
request.payslip_status as payslip_status,
- 'request' as type
+ 'request' as leave_type
from hr_leave as request) leaves
);
""")
diff --git a/addons/hr_holidays/report/hr_leave_reports.xml b/addons/hr_holidays/report/hr_leave_reports.xml
index d4471fa8a72..f818b8fbec6 100644
--- a/addons/hr_holidays/report/hr_leave_reports.xml
+++ b/addons/hr_holidays/report/hr_leave_reports.xml
@@ -25,7 +25,7 @@
<filter string="My Requests" name="my_leaves" domain="[('employee_id.user_id', '=', uid)]"/>
<separator/>
<field name="employee_id"/>
- <field name="department_id"/>
+ <field name="department_id" operator="child_of"/>
<field name="holiday_status_id"/>
<group expand="0" string="Group By">
<filter name="group_name" string="Description" context="{'group_by':'name'}"/>
@@ -49,7 +49,7 @@
<field name="date_from"/>
<field name="date_to"/>
<field name="state"/>
- <field name="type"/>
+ <field name="leave_type"/>
<field name="name"/>
</tree>
</field>
diff --git a/addons/hr_holidays/security/hr_holidays_security.xml b/addons/hr_holidays/security/hr_holidays_security.xml
index 546fe9440fd..3e10c16ca65 100644
--- a/addons/hr_holidays/security/hr_holidays_security.xml
+++ b/addons/hr_holidays/security/hr_holidays_security.xml
@@ -1,9 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
+
+ <record id="group_hr_holidays_team_leader" model="res.groups">
+ <field name="name">Team Leader</field>
+ <field name="category_id" ref="base.module_category_hr_holidays"/>
+ </record>
+
<record id="group_hr_holidays_user" model="res.groups">
<field name="name">Officer</field>
<field name="category_id" ref="base.module_category_hr_holidays"/>
- <field name="implied_ids" eval="[(4, ref('hr.group_hr_user'))]"/>
+ <field name="implied_ids" eval="[(4, ref('hr.group_hr_user')), (4, ref('group_hr_holidays_team_leader'))]"/>
</record>
<record id="group_hr_holidays_manager" model="res.groups">
@@ -35,6 +41,16 @@
<field name="groups" eval="[(4,ref('base.group_user'))]"/>
</record>
+ <record id="hr_leave_rule_team_leader_update" model="ir.rule">
+ <field name="name">Leaves: Team Leader: write on employee's leaves in his team</field>
+ <field name="model_id" ref="model_hr_leave"/>
+ <field name="domain_force">[('employee_id.leave_manager_id', '=' , user.id)]</field>
+ <field name="perm_read" eval="False"/>
+ <field name="perm_create" eval="False"/>
+ <field name="perm_unlink" eval="False"/>
+ <field name="groups" eval="[(4, ref('hr_holidays.group_hr_holidays_team_leader'))]"/>
+ </record>
+
<record id="hr_leave_rule_officer_read" model="ir.rule">
<field name="name">Leaves: officer: read all</field>
<field name="model_id" ref="model_hr_leave"/>
@@ -113,11 +129,11 @@
<field name="groups" eval="[(4, ref('group_hr_holidays_manager'))]"/>
</record>
- <record id="resource_leaves_officer" model="ir.rule">
- <field name="name">Leaves Resources Officer</field>
+ <record id="resource_leaves_team_leader" model="ir.rule">
+ <field name="name">Leaves Resources: Team Leader</field>
<field name="model_id" ref="model_resource_calendar_leaves"/>
<field name="domain_force">[(1,'=',1)]</field>
- <field name="groups" eval="[(4,ref('hr_holidays.group_hr_holidays_user'))]"/>
+ <field name="groups" eval="[(4, ref('hr_holidays.group_hr_holidays_team_leader'))]"/>
</record>
<record id="hr_holidays_status_rule_multi_company" model="ir.rule">
diff --git a/addons/hr_holidays/tests/__init__.py b/addons/hr_holidays/tests/__init__.py
index ba8bdaabb15..9510f75b34b 100644
--- a/addons/hr_holidays/tests/__init__.py
+++ b/addons/hr_holidays/tests/__init__.py
@@ -5,4 +5,5 @@ from . import test_access_rights
from . import test_holidays_flow
from . import test_hr_leave_type
from . import test_accrual_allocations
+from . import test_change_department
from . import test_leave_requests
diff --git a/addons/hr_holidays/tests/test_change_department.py b/addons/hr_holidays/tests/test_change_department.py
new file mode 100644
index 00000000000..6240594e43f
--- /dev/null
+++ b/addons/hr_holidays/tests/test_change_department.py
@@ -0,0 +1,70 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from datetime import datetime
+from dateutil.relativedelta import relativedelta
+
+from odoo import tests
+from odoo.addons.hr_holidays.tests.common import TestHrHolidaysBase
+
+
+class TestChangeDepartment(TestHrHolidaysBase):
+
+ def test_employee_change_department_request_change_department(self):
+ self.HolidaysEmployeeGroup = self.env['hr.leave'].sudo(self.user_employee_id)
+
+ HolidayStatusManagerGroup = self.env['hr.leave.type'].sudo(self.user_hrmanager_id)
+ self.holidays_status_1 = HolidayStatusManagerGroup.create({
+ 'name': 'NotLimitedHR',
+ 'allocation_type': 'no',
+ 'validation_type': 'hr',
+ })
+
+ def create_holiday(name, start, end):
+ return self.HolidaysEmployeeGroup.create({
+ 'name': name,
+ 'employee_id': self.employee_emp_id,
+ 'holiday_status_id': self.holidays_status_1.id,
+ 'date_from': (datetime.today() + relativedelta(days=start)).strftime('%Y-%m-%d %H:%M'),
+ 'date_to': datetime.today() + relativedelta(days=end),
+ 'number_of_days': end-start,
+ })
+
+ # Non approved leave request change department
+ self.employee_emp.department_id = self.rd_dept
+ hol1_employee_group = create_holiday("hol1", 1, 2)
+ self.employee_emp.department_id = self.hr_dept
+ self.assertEqual(hol1_employee_group.department_id, self.hr_dept, 'hr_holidays: non approved leave request should change department if employee change department')
+
+ # Approved passed leave request change department
+ self.employee_emp.department_id = self.hr_dept
+ hol2_employee_group = create_holiday("hol2", -4, -3)
+ hol2_user_group = hol2_employee_group.sudo(self.user_hruser_id)
+ hol2_user_group.action_approve()
+ self.employee_emp.department_id = self.rd_dept
+ self.assertEqual(hol2_employee_group.department_id, self.hr_dept, 'hr_holidays: approved passed leave request should stay in previous department if employee change department')
+
+ # Approved futur leave request change department
+ self.employee_emp.department_id = self.hr_dept
+ hol22_employee_group = create_holiday("hol22", 3, 4)
+ hol22_user_group = hol22_employee_group.sudo(self.user_hruser_id)
+ hol22_user_group.action_approve()
+ self.employee_emp.department_id = self.rd_dept
+ self.assertEqual(hol22_employee_group.department_id, self.rd_dept, 'hr_holidays: approved futur leave request should change department if employee change department')
+
+ # Refused passed leave request change department
+ self.employee_emp.department_id = self.rd_dept
+ hol3_employee_group = create_holiday("hol3", -6, -5)
+ hol3_user_group = hol3_employee_group.sudo(self.user_hruser_id)
+ hol3_user_group.action_refuse()
+ self.employee_emp.department_id = self.hr_dept # Change department
+ self.assertEqual(hol3_employee_group.department_id, self.rd_dept, 'hr_holidays: refused passed leave request should stay in previous department if employee change department')
+
+ # Refused futur leave request change department
+ self.employee_emp.department_id = self.rd_dept
+ hol32_employee_group = create_holiday("hol32", 5, 6)
+ hol32_user_group = hol32_employee_group.sudo(self.user_hruser_id)
+ hol32_user_group.action_refuse()
+ self.employee_emp.department_id = self.hr_dept # Change department
+ self.assertEqual(hol32_employee_group.department_id, self.hr_dept, 'hr_holidays: refused futur leave request should change department if employee change department')
+
diff --git a/addons/hr_holidays/views/hr_leave_allocation_views.xml b/addons/hr_holidays/views/hr_leave_allocation_views.xml
index a9973828ab7..3b1da9e3cbe 100644
--- a/addons/hr_holidays/views/hr_leave_allocation_views.xml
+++ b/addons/hr_holidays/views/hr_leave_allocation_views.xml
@@ -6,6 +6,7 @@
<field name="model">hr.leave.allocation</field>
<field name="arch" type="xml">
<search string="Search allocations">
+ <field name="employee_id"/>
<field name="name"/>
<field name="accrual"/>
<filter domain="[('state','in',('confirm', 'validate1'))]" string="To Approve" name="approve"/>
@@ -24,8 +25,7 @@
<separator/>
<filter string="My Allocations" name="my_leaves" domain="[('employee_id.user_id', '=', uid)]"/>
<separator/>
- <field name="employee_id"/>
- <field name="department_id"/>
+ <field name="department_id" operator="child_of"/>
<field name="holiday_status_id"/>
<separator/>
<filter string="Late Activities" name="activities_overdue"
@@ -172,7 +172,7 @@
<tree decoration-danger="state == 'refuse'" decoration-info="state == 'draft'" string="Allocation Requests"
decoration-bf="message_needaction == True">
<field name="employee_id"/>
- <field name="department_id" invisible="1"/>
+ <field name="department_id"/>
<field name="holiday_status_id"/>
<field name="name"/>
<field name="number_of_days" string="Allocated Days" sum="Total allocated days"/>
@@ -204,6 +204,9 @@
<xpath expr="//field[@name='employee_id']" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
+ <xpath expr="//field[@name='department_id']" position="attributes">
+ <attribute name="invisible">1</attribute>
+ </xpath>
<xpath expr="//button[@name='action_approve']" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
diff --git a/addons/hr_holidays/views/hr_leave_type_views.xml b/addons/hr_holidays/views/hr_leave_type_views.xml
index c3bdc445ec0..ffa98ea5194 100644
--- a/addons/hr_holidays/views/hr_leave_type_views.xml
+++ b/addons/hr_holidays/views/hr_leave_type_views.xml
@@ -7,6 +7,7 @@
<field name="arch" type="xml">
<search string="Search Leave Type">
<field name="name" string="Leave Types"/>
+ <field name="code"/>
<field name="categ_id"/>
</search>
</field>
@@ -41,6 +42,7 @@
</div>
<group>
<group name="description" string="Options">
+ <field name="code"/>
<field name="unpaid"/>
<field name="request_unit" widget="radio"/>
<field name="sequence" groups="base.group_no_one"/>
@@ -56,6 +58,16 @@
<field name="validity_start"/>
<field name="validity_stop"/>
</group>
+ <group name="notification" string="Notification" colspan="2">
+ <group>
+ <field name="leave_notif_subtype_id"
+ domain="[('res_model','=','hr.leave')]"
+ context="{'default_name': name, 'default_res_model': 'hr.leave'}"/>
+ <field name="allocation_notif_subtype_id"
+ domain="[('res_model','=','hr.leave.allocation')]"
+ context="{'default_name': name, 'default_res_model': 'hr.leave.allocation'}"/>
+ </group>
+ </group>
<group name="calendar" string="Calendar" groups="base.group_no_one">
<field name="categ_id"/>
<field name="color_name"/>
@@ -96,6 +108,7 @@
<tree string="Leave Type">
<field name="sequence" widget="handle"/>
<field name="name"/>
+ <field name="code"/>
<field name="allocation_type"/>
<field name="validation_type"/>
<field name="validity_start"/>
diff --git a/addons/hr_holidays/views/hr_leave_views.xml b/addons/hr_holidays/views/hr_leave_views.xml
index 24d0d2b9369..77f0a223f0c 100644
--- a/addons/hr_holidays/views/hr_leave_views.xml
+++ b/addons/hr_holidays/views/hr_leave_views.xml
@@ -20,7 +20,7 @@
<field name="arch" type="xml">
<search string="Search Leave">
<field name="employee_id"/>
- <field name="department_id"/>
+ <field name="department_id" operator="child_of"/>
<field name="holiday_status_id"/>
<field name="name"/>
<filter domain="[('state','in',('confirm','validate1'))]" string="To Approve" name="approve"/>
@@ -31,7 +31,8 @@
<filter string="Unread Messages" name="message_needaction" domain="[('message_needaction','=',True)]"/>
<separator/>
<filter string="My Department Leaves" name="department" domain="[('department_id.member_ids.user_id', '=', uid)]" help="My Department Leaves"/>
- <filter string="My Team Leaves" name="my_team_leaves" domain="[('employee_id.parent_id.user_id', '=', uid)]" groups="hr_holidays.group_hr_holidays_manager" help="Leaves of Your Team Member"/>
+ <filter string="People I Manage" name="managed_people" domain="[('employee_id.parent_id.user_id', '=', uid)]" help="Leaves of people you are manager of"/>
+ <filter string="My Team Leaves" name="my_team_leaves" domain="[('employee_id.leave_manager_id', '=', uid)]" groups="hr_holidays.group_hr_holidays_team_leader" help="Leaves of Your Team Members"/>
<separator/>
<filter string="To Report in Payslip" name="gray" domain="[('payslip_status', '=', False)]" groups="hr_holidays.group_hr_holidays_manager"/>
<separator/>
@@ -289,6 +290,7 @@
<tree decoration-danger="state == 'refuse'" decoration-info="state == ' draft'" string="Leave Requests"
decoration-bf="message_needaction == True">
<field name="employee_id"/>
+ <field name="department_id"/>
<field name="holiday_type" string="Mode" groups="base.group_no_one"/>
<field name="holiday_status_id"/>
<field name="name"/>
@@ -379,18 +381,18 @@
name="Managers"
parent="menu_hr_holidays_root"
sequence="4"
- groups="hr_holidays.group_hr_holidays_manager,hr_holidays.group_hr_holidays_user"/>
+ groups="hr_holidays.group_hr_holidays_manager,hr_holidays.group_hr_holidays_user,hr_holidays.group_hr_holidays_team_leader"/>
<menuitem
id="hr_holidays_menu_manager_approve"
name="To Approve"
parent="menu_hr_holidays_approvals"
- groups="hr_holidays.group_hr_holidays_manager,hr_holidays.group_hr_holidays_user"
+ groups="hr_holidays.group_hr_holidays_manager,hr_holidays.group_hr_holidays_user,hr_holidays.group_hr_holidays_team_leader"
sequence="1"/>
<menuitem
id="hr_holidays_menu_manager_all"
name="All"
parent="menu_hr_holidays_approvals"
- groups="hr_holidays.group_hr_holidays_manager,hr_holidays.group_hr_holidays_user"
+ groups="hr_holidays.group_hr_holidays_manager,hr_holidays.group_hr_holidays_user,hr_holidays.group_hr_holidays_team_leader"
sequence="2"/>
<menuitem
id="hr_holidays_menu_manager_payroll"
@@ -535,14 +537,14 @@
id="menu_open_department_leave_approve"
name="Leaves"
parent="hr_holidays_menu_manager_approve"
- groups="hr_holidays.group_hr_holidays_manager,hr_holidays.group_hr_holidays_user"
+ groups="hr_holidays.group_hr_holidays_manager,hr_holidays.group_hr_holidays_user,hr_holidays.group_hr_holidays_team_leader"
action="hr_leave_action_action_approve_department"
sequence="1"/>
<menuitem
id="menu_open_employee_leave"
name="Leaves"
parent="hr_holidays_menu_manager_all"
- groups="hr_holidays.group_hr_holidays_manager,hr_holidays.group_hr_holidays_user"
+ groups="hr_holidays.group_hr_holidays_manager,hr_holidays.group_hr_holidays_user,hr_holidays.group_hr_holidays_team_leader"
action="hr_leave_action_all"
sequence="1"/>
diff --git a/addons/hr_holidays/views/hr_views.xml b/addons/hr_holidays/views/hr_views.xml
index 967ef412788..5fa3559e97c 100644
--- a/addons/hr_holidays/views/hr_views.xml
+++ b/addons/hr_holidays/views/hr_views.xml
@@ -137,11 +137,12 @@
<field name="inherit_id" ref="hr.view_employee_form"/>
<field name="arch" type="xml">
<xpath expr="//group[@name='active_group']" position="after">
- <group string="Leaves" groups="base.group_no_one">
+ <group string="Leaves">
<label for="remaining_leaves"/>
<div>
<field name="remaining_leaves" class="oe_inline"/> days
</div>
+ <field name="leave_manager_id"/>
</group>
</xpath>
<button name="toggle_active" position="before">
diff --git a/addons/hr_maintenance/models/equipment.py b/addons/hr_maintenance/models/equipment.py
index d66f29fdfc6..f2537086174 100644
--- a/addons/hr_maintenance/models/equipment.py
+++ b/addons/hr_maintenance/models/equipment.py
@@ -65,7 +65,7 @@ class MaintenanceEquipment(models.Model):
def _track_subtype(self, init_values):
self.ensure_one()
if ('employee_id' in init_values and self.employee_id) or ('department_id' in init_values and self.department_id):
- return 'maintenance.mt_mat_assign'
+ return self.env.ref('maintenance.mt_mat_assign')
return super(MaintenanceEquipment, self)._track_subtype(init_values)
diff --git a/addons/hr_org_chart/static/src/scss/variables.scss b/addons/hr_org_chart/static/src/scss/variables.scss
index 9abf85776b3..142ebf9ab46 100644
--- a/addons/hr_org_chart/static/src/scss/variables.scss
+++ b/addons/hr_org_chart/static/src/scss/variables.scss
@@ -1,10 +1,10 @@
-$o-hr-org-chart-bg: white !default;
-$o-hr-org-chart-border-color: $o-brand-secondary !default;
+$o-hr-org-chart-bg: white;
+$o-hr-org-chart-border-color: $o-brand-secondary;
-$o-hr-org-chart-entry-v-gap: 6px !default;
-$o-hr-org-chart-entry-pic-size: 46px !default;
-$o-hr-org-chart-entry-line-w: 1px !default;
-$o-hr-org-chart-entry-border-color: darken($o-hr-org-chart-bg, 25%) !default;
+$o-hr-org-chart-entry-v-gap: 6px;
+$o-hr-org-chart-entry-pic-size: 46px;
+$o-hr-org-chart-entry-line-w: 1px;
+$o-hr-org-chart-entry-border-color: darken($o-hr-org-chart-bg, 25%);
// MIXINS
@mixin o-hr-org-chart-line {
diff --git a/addons/hr_org_chart/views/hr_templates.xml b/addons/hr_org_chart/views/hr_templates.xml
index b7ed3215ae8..5ee5e9e753b 100644
--- a/addons/hr_org_chart/views/hr_templates.xml
+++ b/addons/hr_org_chart/views/hr_templates.xml
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
- <template id="_assets_backend_helpers" inherit_id="web._assets_backend_helpers">
- <xpath expr="//link" position="before">
+ <template id="_assets_primary_variables" inherit_id="web._assets_primary_variables">
+ <xpath expr="//link[last()]" position="after">
<link rel="stylesheet" type="text/scss" href="/hr_org_chart/static/src/scss/variables.scss"/>
</xpath>
</template>
diff --git a/addons/hr_payroll/models/__init__.py b/addons/hr_payroll/models/__init__.py
index 99a241743cf..47e89d21c90 100644
--- a/addons/hr_payroll/models/__init__.py
+++ b/addons/hr_payroll/models/__init__.py
@@ -1,6 +1,7 @@
#-*- coding:utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from . import browsable_object
from . import hr_contract
from . import hr_employee
from . import res_config_settings
diff --git a/addons/hr_payroll/models/browsable_object.py b/addons/hr_payroll/models/browsable_object.py
new file mode 100644
index 00000000000..9d32fb2cec2
--- /dev/null
+++ b/addons/hr_payroll/models/browsable_object.py
@@ -0,0 +1,62 @@
+#-*- coding:utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields
+
+
+class BrowsableObject(object):
+ def __init__(self, employee_id, dict, env):
+ self.employee_id = employee_id
+ self.dict = dict
+ self.env = env
+
+ def __getattr__(self, attr):
+ return attr in self.dict and self.dict.__getitem__(attr) or 0.0
+
+class InputLine(BrowsableObject):
+ """a class that will be used into the python code, mainly for usability purposes"""
+ def sum(self, code, from_date, to_date=None):
+ if to_date is None:
+ to_date = fields.Date.today()
+ self.env.cr.execute("""
+ SELECT sum(amount) as sum
+ FROM hr_payslip as hp, hr_payslip_input as pi
+ WHERE hp.employee_id = %s AND hp.state = 'done'
+ AND hp.date_from >= %s AND hp.date_to <= %s AND hp.id = pi.payslip_id AND pi.code = %s""",
+ (self.employee_id, from_date, to_date, code))
+ return self.env.cr.fetchone()[0] or 0.0
+
+class WorkedDays(BrowsableObject):
+ """a class that will be used into the python code, mainly for usability purposes"""
+ def _sum(self, code, from_date, to_date=None):
+ if to_date is None:
+ to_date = fields.Date.today()
+ self.env.cr.execute("""
+ SELECT sum(number_of_days) as number_of_days, sum(number_of_hours) as number_of_hours
+ FROM hr_payslip as hp, hr_payslip_worked_days as pi
+ WHERE hp.employee_id = %s AND hp.state = 'done'
+ AND hp.date_from >= %s AND hp.date_to <= %s AND hp.id = pi.payslip_id AND pi.code = %s""",
+ (self.employee_id, from_date, to_date, code))
+ return self.env.cr.fetchone()
+
+ def sum(self, code, from_date, to_date=None):
+ res = self._sum(code, from_date, to_date)
+ return res and res[0] or 0.0
+
+ def sum_hours(self, code, from_date, to_date=None):
+ res = self._sum(code, from_date, to_date)
+ return res and res[1] or 0.0
+
+class Payslips(BrowsableObject):
+ """a class that will be used into the python code, mainly for usability purposes"""
+
+ def sum(self, code, from_date, to_date=None):
+ if to_date is None:
+ to_date = fields.Date.today()
+ self.env.cr.execute("""SELECT sum(case when hp.credit_note = False then (pl.total) else (-pl.total) end)
+ FROM hr_payslip as hp, hr_payslip_line as pl
+ WHERE hp.employee_id = %s AND hp.state = 'done'
+ AND hp.date_from >= %s AND hp.date_to <= %s AND hp.id = pl.slip_id AND pl.code = %s""",
+ (self.employee_id, from_date, to_date, code))
+ res = self.env.cr.fetchone()
+ return res and res[0] or 0.0
diff --git a/addons/hr_payroll/models/hr_contract.py b/addons/hr_payroll/models/hr_contract.py
index 00fa9750d12..278f2537412 100644
--- a/addons/hr_payroll/models/hr_contract.py
+++ b/addons/hr_payroll/models/hr_contract.py
@@ -5,10 +5,6 @@ from odoo import api, fields, models
class HrContract(models.Model):
- """
- Employee contract based on the visa, work permits
- allows to configure different Salary structure
- """
_inherit = 'hr.contract'
_description = 'Employee Contract'
@@ -32,10 +28,7 @@ class HrContract(models.Model):
then first level children and so on) and without duplicata
"""
structures = self.mapped('struct_id')
- if not structures:
- return []
- # YTI TODO return browse records
- return list(set(structures._get_parent_structure().ids))
+ return structures._get_parent_structure() if structures else self.env['hr.payroll.structure']
@api.multi
def get_attribute(self, code, attribute):
diff --git a/addons/hr_payroll/models/hr_payslip.py b/addons/hr_payroll/models/hr_payslip.py
index 18eb8408c4a..e15d1379586 100644
--- a/addons/hr_payroll/models/hr_payslip.py
+++ b/addons/hr_payroll/models/hr_payslip.py
@@ -8,8 +8,10 @@ from pytz import timezone
from odoo import api, fields, models, tools, _
from odoo.addons import decimal_precision as dp
+from odoo.addons.hr_payroll.models.browsable_object import BrowsableObject, InputLine, WorkedDays, Payslips
from odoo.exceptions import UserError, ValidationError
+
class HrPayslip(models.Model):
_name = 'hr.payslip'
_description = 'Pay Slip'
@@ -118,33 +120,11 @@ class HrPayslip(models.Model):
}
@api.multi
- def check_done(self):
- return True
-
- @api.multi
def unlink(self):
if any(self.filtered(lambda payslip: payslip.state not in ('draft', 'cancel'))):
raise UserError(_('You cannot delete a payslip which is not draft or cancelled!'))
return super(HrPayslip, self).unlink()
- # TODO move this function into hr_contract module, on hr.employee object
- @api.model
- def get_contract(self, employee, date_from, date_to):
- """
- @param employee: recordset of employee
- @param date_from: date field
- @param date_to: date field
- @return: returns the ids of all the contracts for the given employee that need to be considered for the given dates
- """
- # a contract is valid if it ends between the given dates
- clause_1 = ['&', ('date_end', '<=', date_to), ('date_end', '>=', date_from)]
- # OR if it starts between the given dates
- clause_2 = ['&', ('date_start', '<=', date_to), ('date_start', '>=', date_from)]
- # OR if it starts before the date_from and finish after the date_end (or never finish)
- clause_3 = ['&', ('date_start', '<=', date_from), '|', ('date_end', '=', False), ('date_end', '>=', date_to)]
- clause_final = [('employee_id', '=', employee.id), ('state', '=', 'open'), '|', '|'] + clause_1 + clause_2 + clause_3
- return self.env['hr.contract'].search(clause_final).ids
-
@api.multi
def compute_sheet(self):
for payslip in self:
@@ -153,9 +133,9 @@ class HrPayslip(models.Model):
payslip.line_ids.unlink()
# set the list of contract for which the rules have to be applied
# if we don't give the contract, then the rules to apply should be for all current contracts of the employee
- contract_ids = payslip.contract_id.ids or \
- self.get_contract(payslip.employee_id, payslip.date_from, payslip.date_to)
- lines = [(0, 0, line) for line in self._get_payslip_lines(contract_ids, payslip.id)]
+ contracts = payslip.contract_id or \
+ payslip.employee_id._get_contracts(payslip.date_from, payslip.date_to)
+ lines = [(0, 0, line) for line in payslip._get_payslip_lines(contracts)]
payslip.write({'line_ids': lines, 'number': number})
return True
@@ -181,7 +161,7 @@ class HrPayslip(models.Model):
current_leave_struct = leaves.setdefault(holiday.holiday_status_id, {
'name': holiday.holiday_status_id.name,
'sequence': 5,
- 'code': holiday.holiday_status_id.name,
+ 'code': holiday.holiday_status_id.code or holiday.holiday_status_id.name,
'number_of_days': 0.0,
'number_of_hours': 0.0,
'contract_id': contract.id,
@@ -214,8 +194,8 @@ class HrPayslip(models.Model):
def get_inputs(self, contracts, date_from, date_to):
res = []
- structure_ids = contracts.get_all_structures()
- rule_ids = self.env['hr.payroll.structure'].browse(structure_ids).get_all_rules()
+ structures = contracts.get_all_structures()
+ rule_ids = structures.get_all_rules()
sorted_rule_ids = [id for id, sequence in sorted(rule_ids, key=lambda x:x[1])]
inputs = self.env['hr.salary.rule'].browse(sorted_rule_ids).mapped('input_ids')
@@ -229,98 +209,43 @@ class HrPayslip(models.Model):
res += [input_data]
return res
+ # YTI TODO: pass recordset as argument
@api.model
- def _get_payslip_lines(self, contract_ids, payslip_id):
+ def _get_payslip_lines(self, contracts):
+ # YTI TODO: Move into browsable_object.py ?
def _sum_salary_rule_category(localdict, category, amount):
if category.parent_id:
localdict = _sum_salary_rule_category(localdict, category.parent_id, amount)
localdict['categories'].dict[category.code] = category.code in localdict['categories'].dict and localdict['categories'].dict[category.code] + amount or amount
return localdict
- class BrowsableObject(object):
- def __init__(self, employee_id, dict, env):
- self.employee_id = employee_id
- self.dict = dict
- self.env = env
-
- def __getattr__(self, attr):
- return attr in self.dict and self.dict.__getitem__(attr) or 0.0
-
- class InputLine(BrowsableObject):
- """a class that will be used into the python code, mainly for usability purposes"""
- def sum(self, code, from_date, to_date=None):
- if to_date is None:
- to_date = fields.Date.today()
- self.env.cr.execute("""
- SELECT sum(amount) as sum
- FROM hr_payslip as hp, hr_payslip_input as pi
- WHERE hp.employee_id = %s AND hp.state = 'done'
- AND hp.date_from >= %s AND hp.date_to <= %s AND hp.id = pi.payslip_id AND pi.code = %s""",
- (self.employee_id, from_date, to_date, code))
- return self.env.cr.fetchone()[0] or 0.0
-
- class WorkedDays(BrowsableObject):
- """a class that will be used into the python code, mainly for usability purposes"""
- def _sum(self, code, from_date, to_date=None):
- if to_date is None:
- to_date = fields.Date.today()
- self.env.cr.execute("""
- SELECT sum(number_of_days) as number_of_days, sum(number_of_hours) as number_of_hours
- FROM hr_payslip as hp, hr_payslip_worked_days as pi
- WHERE hp.employee_id = %s AND hp.state = 'done'
- AND hp.date_from >= %s AND hp.date_to <= %s AND hp.id = pi.payslip_id AND pi.code = %s""",
- (self.employee_id, from_date, to_date, code))
- return self.env.cr.fetchone()
-
- def sum(self, code, from_date, to_date=None):
- res = self._sum(code, from_date, to_date)
- return res and res[0] or 0.0
-
- def sum_hours(self, code, from_date, to_date=None):
- res = self._sum(code, from_date, to_date)
- return res and res[1] or 0.0
-
- class Payslips(BrowsableObject):
- """a class that will be used into the python code, mainly for usability purposes"""
-
- def sum(self, code, from_date, to_date=None):
- if to_date is None:
- to_date = fields.Date.today()
- self.env.cr.execute("""SELECT sum(case when hp.credit_note = False then (pl.total) else (-pl.total) end)
- FROM hr_payslip as hp, hr_payslip_line as pl
- WHERE hp.employee_id = %s AND hp.state = 'done'
- AND hp.date_from >= %s AND hp.date_to <= %s AND hp.id = pl.slip_id AND pl.code = %s""",
- (self.employee_id, from_date, to_date, code))
- res = self.env.cr.fetchone()
- return res and res[0] or 0.0
-
+ self.ensure_one()
#we keep a dict with the result because a value can be overwritten by another rule with the same code
result_dict = {}
rules_dict = {}
worked_days_dict = {}
inputs_dict = {}
blacklist = []
- payslip = self.env['hr.payslip'].browse(payslip_id)
- for worked_days_line in payslip.worked_days_line_ids:
- worked_days_dict[worked_days_line.code] = worked_days_line
- for input_line in payslip.input_line_ids:
- inputs_dict[input_line.code] = input_line
-
- categories = BrowsableObject(payslip.employee_id.id, {}, self.env)
- inputs = InputLine(payslip.employee_id.id, inputs_dict, self.env)
- worked_days = WorkedDays(payslip.employee_id.id, worked_days_dict, self.env)
- payslips = Payslips(payslip.employee_id.id, payslip, self.env)
- rules = BrowsableObject(payslip.employee_id.id, rules_dict, self.env)
+
+ for code in self.worked_days_line_ids.mapped('code'):
+ worked_days_dict[code] = self.worked_days_line_ids.filtered(lambda l: l.code == code)
+ for code in self.input_line_ids.mapped('code'):
+ inputs_dict[code] = self.input_line_ids.filtered(lambda l: l.code == code)
+
+ categories = BrowsableObject(self.employee_id.id, {}, self.env)
+ inputs = InputLine(self.employee_id.id, inputs_dict, self.env)
+ worked_days = WorkedDays(self.employee_id.id, worked_days_dict, self.env)
+ payslips = Payslips(self.employee_id.id, self, self.env)
+ rules = BrowsableObject(self.employee_id.id, rules_dict, self.env)
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)
- if len(contracts) == 1 and payslip.struct_id:
- structure_ids = list(set(payslip.struct_id._get_parent_structure().ids))
+ if len(contracts) == 1 and self.struct_id:
+ structures = self.struct_id._get_parent_structure()
else:
- structure_ids = contracts.get_all_structures()
+ structures = 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()
+ rule_ids = structures.get_all_rules()
#run the rules by sequence
sorted_rule_ids = [id for id, sequence in sorted(rule_ids, key=lambda x:x[1])]
sorted_rules = self.env['hr.salary.rule'].browse(sorted_rule_ids)
@@ -377,7 +302,7 @@ class HrPayslip(models.Model):
return list(result_dict.values())
# YTI TODO To rename. This method is not really an onchange, as it is not in any view
- # employee_id and contract_id could be browse records
+ # employee_id and contract_id could be browse records. Contract_id should be a record
@api.multi
def onchange_employee_id(self, date_from, date_to, employee_id=False, contract_id=False):
#defaults
@@ -404,20 +329,21 @@ class HrPayslip(models.Model):
'company_id': employee.company_id.id,
})
+ contracts = self.env['hr.contract']
if not self.env.context.get('contract'):
#fill with the first contract of the employee
- contract_ids = self.get_contract(employee, date_from, date_to)
+ contracts = employee._get_contracts(date_from, date_to)
else:
if contract_id:
#set the list of contract for which the input have to be filled
- contract_ids = [contract_id]
+ contracts = self.env['hr.contract'].browse(contract_id)
else:
#if we don't give the contract, then the input to fill should be for all current contracts of the employee
- contract_ids = self.get_contract(employee, date_from, date_to)
+ contracts = employee._get_contracts(date_from, date_to)
- if not contract_ids:
+ if not contracts:
return res
- contract = self.env['hr.contract'].browse(contract_ids[0])
+ contract = contracts[0]
res['value'].update({
'contract_id': contract.id
})
@@ -428,7 +354,6 @@ class HrPayslip(models.Model):
'struct_id': struct.id,
})
#computation of the salary input
- contracts = self.env['hr.contract'].browse(contract_ids)
worked_days_line_ids = self.get_worked_day_lines(contracts, date_from, date_to)
input_line_ids = self.get_inputs(contracts, date_from, date_to)
res['value'].update({
@@ -446,25 +371,24 @@ class HrPayslip(models.Model):
employee = self.employee_id
date_from = self.date_from
date_to = self.date_to
- contract_ids = []
ttyme = datetime.combine(fields.Date.from_string(date_from), time.min)
locale = self.env.context.get('lang') or 'en_US'
self.name = _('Salary Slip of %s for %s') % (employee.name, tools.ustr(babel.dates.format_date(date=ttyme, format='MMMM-y', locale=locale)))
self.company_id = employee.company_id
+ contracts = self.env['hr.contract']
if not self.env.context.get('contract') or not self.contract_id:
- contract_ids = self.get_contract(employee, date_from, date_to)
- if not contract_ids:
+ contracts = employee._get_contracts(date_from, date_to)
+ if not contracts:
return
- self.contract_id = self.env['hr.contract'].browse(contract_ids[0])
+ self.contract_id = contracts[0]
if not self.contract_id.struct_id:
return
self.struct_id = self.contract_id.struct_id
#computation of the salary input
- contracts = self.env['hr.contract'].browse(contract_ids)
worked_days_line_ids = self.get_worked_day_lines(contracts, date_from, date_to)
worked_days_lines = self.worked_days_line_ids.browse([])
for r in worked_days_line_ids:
@@ -498,7 +422,7 @@ class HrPayslipLine(models.Model):
_name = 'hr.payslip.line'
_inherit = 'hr.salary.rule'
_description = 'Payslip Line'
- _order = 'contract_id, sequence'
+ _order = 'contract_id, sequence, code'
slip_id = fields.Many2one('hr.payslip', string='Pay Slip', required=True, ondelete='cascade')
salary_rule_id = fields.Many2one('hr.salary.rule', string='Rule', required=True)
@@ -583,4 +507,5 @@ class HrPayslipRun(models.Model):
@api.multi
def close_payslip_run(self):
+ self.mapped('slip_ids').filtered(lambda payslip: payslip.state != 'done').action_payslip_done()
return self.write({'state': 'close'})
diff --git a/addons/hr_payroll/models/hr_salary_rule.py b/addons/hr_payroll/models/hr_salary_rule.py
index 44dff7ee4bf..75a899f9eca 100644
--- a/addons/hr_payroll/models/hr_salary_rule.py
+++ b/addons/hr_payroll/models/hr_salary_rule.py
@@ -57,7 +57,7 @@ class HrPayrollStructure(models.Model):
parent = self.mapped('parent_id')
if parent:
parent = parent._get_parent_structure()
- return parent + self
+ return parent | self
class HrContributionRegister(models.Model):
@@ -200,21 +200,21 @@ class HrSalaryRule(models.Model):
if self.amount_select == 'fix':
try:
return self.amount_fix, float(safe_eval(self.quantity, localdict)), 100.0
- except:
- raise UserError(_('Wrong quantity defined for salary rule %s (%s).') % (self.name, self.code))
+ except Exception as e:
+ raise UserError(_('Wrong quantity defined for salary rule %s (%s).\nError: %s') % (self.name, self.code, e))
elif self.amount_select == 'percentage':
try:
return (float(safe_eval(self.amount_percentage_base, localdict)),
float(safe_eval(self.quantity, localdict)),
self.amount_percentage)
- except:
- raise UserError(_('Wrong percentage base or quantity defined for salary rule %s (%s).') % (self.name, self.code))
+ except Exception as e:
+ raise UserError(_('Wrong percentage base or quantity defined for salary rule %s (%s).\nError: %s') % (self.name, self.code, e))
else:
try:
safe_eval(self.amount_python_compute, localdict, mode='exec', nocopy=True)
return float(localdict['result']), 'result_qty' in localdict and localdict['result_qty'] or 1.0, 'result_rate' in localdict and localdict['result_rate'] or 100.0
- except:
- raise UserError(_('Wrong python code defined for salary rule %s (%s).') % (self.name, self.code))
+ except Exception as e:
+ raise UserError(_('Wrong python code defined for salary rule %s (%s).\nError: %s') % (self.name, self.code, e))
@api.multi
def _satisfy_condition(self, localdict):
diff --git a/addons/hr_payroll/views/hr_salary_rule_views.xml b/addons/hr_payroll/views/hr_salary_rule_views.xml
index be3462476c6..31f11045fac 100644
--- a/addons/hr_payroll/views/hr_salary_rule_views.xml
+++ b/addons/hr_payroll/views/hr_salary_rule_views.xml
@@ -68,14 +68,15 @@
<field name="model">hr.payroll.structure</field>
<field name="arch" type="xml">
<form string="Employee Function">
- <group col="4">
+ <sheet>
+ <group name="salary_details" col="4">
<field name="name"/>
<field name="code"/>
<field name="parent_id"/>
<field name="company_id" groups="base.group_multi_company" options="{'no_create': True}"/>
</group>
<notebook colspan="4">
- <page string="Salary Rules">
+ <page name="salary_rules" string="Salary Rules">
<field name="rule_ids" domain="[('parent_rule_id','=',False)]">
<tree>
<field name="name"/>
@@ -87,6 +88,7 @@
</field>
</page>
</notebook>
+ </sheet>
</form>
</field>
</record>
@@ -109,14 +111,16 @@
<field name="model">hr.salary.rule.category</field>
<field name="arch" type="xml">
<form string="Salary Categories">
- <group col="4">
+ <sheet>
+ <group name="category_details" col="4">
<field name="name"/>
<field name="code"/>
<field name="parent_id"/>
</group>
- <group string="Notes">
+ <group name="notes" string="Notes">
<field name="note" nolabel="1"/>
</group>
+ </sheet>
</form>
</field>
</record>
@@ -205,15 +209,17 @@
<field name="model">hr.contribution.register</field>
<field name="arch" type="xml">
<form string="Contribution">
- <group>
+ <sheet>
+ <group name="general_info">
<field name="name"/>
<field name="partner_id"/>
<field name="company_id" groups="base.group_multi_company" options="{'no_create': True}" class="oe_inline"/>
</group>
<newline/>
- <group>
+ <group name="description">
<separator string="Description"/><newline/>
<field name="note" nolabel="1"/></group>
+ </sheet>
</form>
</field>
</record>
@@ -302,12 +308,13 @@
<field name="model">hr.salary.rule</field>
<field name="arch" type="xml">
<form string="Salary Rules">
+ <sheet>
<label for="name" class="oe_edit_only"/>
<h1><field name="name"/></h1>
<label for="category_id" class="oe_edit_only"/>
<h2><field name="category_id"/></h2>
- <group col="4">
+ <group name="main_details" col="4">
<field name="code"/>
<field name="sequence" />
<field name="active"/>
@@ -315,8 +322,8 @@
<field name="company_id" options="{'no_create': True}" groups="base.group_multi_company"/>
</group>
<notebook colspan="6">
- <page string="General">
- <group col="4">
+ <page name="general" string="General">
+ <group name="general_conditions" col="4">
<separator colspan="4" string="Conditions"/>
<field name="condition_select"/><newline/>
<field name="condition_python" attrs="{'invisible':[('condition_select','!=','python')], 'required': [('condition_select','=','python')]}" colspan="4"/> <newline/>
@@ -339,7 +346,7 @@
<separator string="Children Definition"/>
<field name="child_ids"/>
</page>
- <page string="Inputs">
+ <page name="inputs" string="Inputs">
<field name="input_ids" mode="tree">
<tree string="Input Data" editable="bottom">
<field name="name"/>
@@ -347,10 +354,11 @@
</tree>
</field>
</page>
- <page string="Description">
+ <page name="description" string="Description">
<field name="note"/>
</page>
</notebook>
+ </sheet>
</form>
</field>
</record>
diff --git a/addons/hr_payroll/wizard/hr_payroll_payslips_by_employees.py b/addons/hr_payroll/wizard/hr_payroll_payslips_by_employees.py
index d9a59cba3a0..d2097da5306 100644
--- a/addons/hr_payroll/wizard/hr_payroll_payslips_by_employees.py
+++ b/addons/hr_payroll/wizard/hr_payroll_payslips_by_employees.py
@@ -9,7 +9,12 @@ class HrPayslipEmployees(models.TransientModel):
_name = 'hr.payslip.employees'
_description = 'Generate payslips for all selected employees'
- employee_ids = fields.Many2many('hr.employee', 'hr_employee_group_rel', 'payslip_id', 'employee_id', 'Employees')
+ employee_ids = fields.Many2many('hr.employee', 'hr_employee_group_rel', 'payslip_id', 'employee_id', 'Employees',
+ domain=lambda self: self._get_available_contracts_domain())
+
+ def _get_available_contracts_domain(self):
+ return [('contract_ids.state', 'in', ('open', 'pending'))]
+
@api.multi
def compute_sheet(self):
diff --git a/addons/hr_payroll_account/views/hr_payroll_account_views.xml b/addons/hr_payroll_account/views/hr_payroll_account_views.xml
index 9617c903395..fa4d9cd6258 100644
--- a/addons/hr_payroll_account/views/hr_payroll_account_views.xml
+++ b/addons/hr_payroll_account/views/hr_payroll_account_views.xml
@@ -20,7 +20,7 @@
<field name="model">hr.salary.rule</field>
<field name="inherit_id" ref="hr_payroll.hr_salary_rule_form"/>
<field name="arch" type="xml">
- <xpath expr="/form/notebook/page[@name='rules']" position="after">
+ <xpath expr="//page[@name='rules']" position="after">
<page string="Accounting">
<group colspan="4">
<field name="account_debit" />
diff --git a/addons/hr_presence/__init__.py b/addons/hr_presence/__init__.py
new file mode 100644
index 00000000000..7d34c7c054a
--- /dev/null
+++ b/addons/hr_presence/__init__.py
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import controllers
+from . import models
diff --git a/addons/hr_presence/__manifest__.py b/addons/hr_presence/__manifest__.py
new file mode 100644
index 00000000000..f4439dd7ac9
--- /dev/null
+++ b/addons/hr_presence/__manifest__.py
@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+{
+ 'name': 'Employee Presence Control',
+ 'version': '1.0',
+ 'category': 'Human Resources',
+ 'description': """
+Control Employees Presence
+==========================
+
+Based on:
+ * The IP Address
+ * The User's Session
+ * The Sent Emails
+
+Allows to contact directly the employee in case of unjustified absence.
+ """,
+ 'depends': ['hr', 'hr_holidays', 'sms'],
+ 'data': [
+ 'views/res_config_settings_views.xml',
+ 'views/hr_employee_views.xml',
+ 'data/ir_actions_server.xml',
+ 'data/mail_data.xml',
+ ],
+ 'demo': [],
+ 'installable': True,
+ 'auto_install': False,
+}
diff --git a/addons/hr_presence/controllers/__init__.py b/addons/hr_presence/controllers/__init__.py
new file mode 100644
index 00000000000..9304f48d4e5
--- /dev/null
+++ b/addons/hr_presence/controllers/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import bus_controller \ No newline at end of file
diff --git a/addons/hr_presence/controllers/bus_controller.py b/addons/hr_presence/controllers/bus_controller.py
new file mode 100644
index 00000000000..685ac4e58c5
--- /dev/null
+++ b/addons/hr_presence/controllers/bus_controller.py
@@ -0,0 +1,24 @@
+# -*- coding: utf-8 -*-
+
+from odoo import registry, SUPERUSER_ID
+from odoo.api import Environment
+from odoo.addons.bus.controllers.main import BusController
+from odoo.fields import Datetime
+from odoo.http import Controller, request, route
+
+
+class BusController(BusController):
+
+ @route('/longpolling/poll', type="json", auth="public")
+ def poll(self, channels, last, options=None):
+ if request.env.user.has_group('base.group_user'):
+ ip_address = request.httprequest.remote_addr
+ users_log = request.env['res.users.log'].search_count([
+ ('create_uid', '=', request.env.user.id),
+ ('ip', '=', ip_address),
+ ('create_date', '>=', Datetime.to_string(Datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)))])
+ if not users_log:
+ with registry(request.env.cr.dbname).cursor() as cr:
+ env = Environment(cr, request.env.user.id, {})
+ env['res.users.log'].create({'ip': ip_address})
+ return super(BusController, self).poll(channels, last, options=options)
diff --git a/addons/hr_presence/data/ir_actions_server.xml b/addons/hr_presence/data/ir_actions_server.xml
new file mode 100644
index 00000000000..7c18b7f7798
--- /dev/null
+++ b/addons/hr_presence/data/ir_actions_server.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <record id="ir_actions_server_action_open_presence_view" model="ir.actions.server">
+ <field name="name">Compute presence and open presence view</field>
+ <field name="model_id" ref="hr.model_hr_employee" />
+ <field name="state">code</field>
+ <field name="code">
+action = env['hr.employee']._action_open_presence_view()
+ </field>
+ </record>
+
+ <menuitem id="menu_hr_presence_reports" name="Reports" parent="hr.menu_hr_root" sequence="95" groups="hr.group_hr_manager"/>
+
+ <menuitem id="menu_hr_presence_view" name="Employee Presence" action="ir_actions_server_action_open_presence_view" parent="menu_hr_presence_reports" groups="hr.group_hr_manager"/>
+
+</odoo>
diff --git a/addons/hr_presence/data/mail_data.xml b/addons/hr_presence/data/mail_data.xml
new file mode 100644
index 00000000000..f2ab1ca0579
--- /dev/null
+++ b/addons/hr_presence/data/mail_data.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <data noupdate="1">
+ <record id="mail_template_presence" model="mail.template">
+ <field name="name">Presence: Contact Employee</field>
+ <field name="model_id" ref="hr.model_hr_employee"/>
+ <field name="subject">Unexpected Absence</field>
+ <field name="email_from">${ctx['email_from'] or ''}</field>
+ <field name="email_to">${ctx['email_to'] or ''}</field>
+ <field name="auto_delete" eval="False"/>
+ <field name="body_html" type="xml">
+ <div>
+ Dear ${object.name},<br/><br/>
+ Exception made if there was a mistake of ours, it seems that you are not at your office and there is not request of leaves from you.<br/>
+ Please, take appropriate measures in order to carry out this work absence.<br/>
+ Do not hesitate to contact your manager or the human resource department.
+ <br/>Best Regards,<br/><br/>
+ </div>
+ </field>
+ </record>
+ </data>
+</odoo>
diff --git a/addons/hr_presence/models/__init__.py b/addons/hr_presence/models/__init__.py
new file mode 100644
index 00000000000..996b19c0a45
--- /dev/null
+++ b/addons/hr_presence/models/__init__.py
@@ -0,0 +1,7 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import hr_employee
+from . import res_company
+from . import res_config_settings
+from . import res_users_log
diff --git a/addons/hr_presence/models/hr_employee.py b/addons/hr_presence/models/hr_employee.py
new file mode 100644
index 00000000000..db3d5379843
--- /dev/null
+++ b/addons/hr_presence/models/hr_employee.py
@@ -0,0 +1,164 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import logging
+
+from odoo import fields, models, _
+from odoo.exceptions import UserError
+from odoo.fields import Datetime
+
+_logger = logging.getLogger(__name__)
+
+
+class ResCompany(models.Model):
+ _inherit = 'hr.employee'
+
+ hr_presence_state = fields.Selection([
+ ('present', 'Present'),
+ ('absent', 'Absent'),
+ ('to_define', 'To Define')], default='to_define', groups='hr.group_hr_manager')
+
+
+ def _action_open_presence_view(self):
+ # Compute the presence/absence for the employees on the same
+ # company than the HR/manager. Then opens the kanban view
+ # of the employees with an undefined presence/absence
+
+ _logger.info("Employees presence checked by: %s" % self.env.user.name)
+
+ company = self.env.user.company_id
+ if not company.hr_presence_last_compute_date or \
+ company.hr_presence_last_compute_date.day != Datetime.now().day:
+ self.env['hr.employee'].search([
+ ('department_id.company_id', '=', company.id)
+ ]).write({'hr_presence_state': 'to_define'})
+
+ employees = self.env['hr.employee'].search([
+ ('department_id.company_id', '=', company.id),
+ ('user_id', '!=', False),
+ ('hr_presence_state', '=', 'to_define')])
+
+ # Remove employees on holidays
+ leaves = self.env['hr.leave'].search([
+ ('state', '=', 'validate'),
+ ('date_from', '<=', Datetime.to_string(Datetime.now())),
+ ('date_to', '>=', Datetime.to_string(Datetime.now()))])
+ employees_on_holiday = leaves.mapped('employee_id')
+ employees_on_holiday.write({'hr_presence_state': 'absent'})
+ employees = employees - employees_on_holiday
+
+ # Check on system login
+ if self.env['ir.config_parameter'].sudo().get_param('hr_presence.hr_presence_control_login'):
+ online_employees = employees.filtered(lambda employee: employee.user_id.im_status in ['away', 'online'])
+ online_employees.write({'hr_presence_state': 'present'})
+ employees = employees - online_employees
+
+ # Check on IP
+ if self.env['ir.config_parameter'].sudo().get_param('hr_presence.hr_presence_control_ip'):
+ ip_list = company.hr_presence_control_ip_list
+ ip_list = ip_list.split(',') if ip_list else []
+ ip_employees = self.env['hr.employee']
+ for employee in employees:
+ employee_ips = self.env['res.users.log'].search([
+ ('create_uid', '=', employee.user_id.id),
+ ('ip', '!=', False),
+ ('create_date', '>=', Datetime.to_string(Datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)))]
+ ).mapped('ip')
+ if any([ip in ip_list for ip in employee_ips]):
+ ip_employees |= employee
+ ip_employees.write({'hr_presence_state': 'present'})
+ employees = employees - ip_employees
+
+ # Check on sent emails
+ if self.env['ir.config_parameter'].sudo().get_param('hr_presence.hr_presence_control_email'):
+ email_employees = self.env['hr.employee']
+ threshold = company.hr_presence_control_email_amount
+ for employee in employees:
+ sent_emails = self.env['mail.message'].search_count([
+ ('author_id', '=', employee.user_id.partner_id.id),
+ ('date', '>=', Datetime.to_string(Datetime.now().replace(hour=0, minute=0, second=0, microsecond=0))),
+ ('date', '<=', Datetime.to_string(Datetime.now()))])
+ if sent_emails >= threshold:
+ email_employees |= employee
+ email_employees.write({'hr_presence_state': 'present'})
+ employees = employees - email_employees
+
+ company.hr_presence_last_compute_date = Datetime.now()
+
+ return {
+ "type": "ir.actions.act_window",
+ "res_model": "hr.employee",
+ "views": [[self.env.ref('hr_presence.hr_employee_view_kanban').id, "kanban"], [False, "tree"], [False, "form"]],
+ 'view_mode': 'kanban,tree,form',
+ "domain": [],
+ "name": "Employee's Presence to Define",
+ "context": {'search_default_group_hr_presence_state': 1},
+ }
+
+ def action_set_present(self):
+ if not self.env.user.has_group('hr.group_hr_manager'):
+ raise UserError(_("You don't have the right to do this. Please contact an Administrator."))
+ self.write({'hr_presence_state': 'present'})
+
+ def action_open_leave_request(self):
+ self.ensure_one()
+ return {
+ "type": "ir.actions.act_window",
+ "res_model": "hr.leave",
+ "views": [[False, "form"]],
+ "view_mode": 'form',
+ "context": {'default_employee_id': self.id},
+ }
+
+ def action_send_sms(self):
+ self.ensure_one()
+ if not self.env.user.has_group('hr.group_hr_manager'):
+ raise UserError(_("You don't have the right to do this. Please contact an Administrator."))
+ if not self.mobile_phone:
+ raise UserError(_("There is no professional phone for this employee."))
+ body = _("""Exception made if there was a mistake of ours, it seems that you are not at your office and there is not request of leaves from you.
+Please, take appropriate measures in order to carry out this work absence.
+Do not hesitate to contact your manager or the human resource department.""")
+ return {
+ "type": "ir.actions.act_window",
+ "res_model": "sms.send_sms",
+ "view_mode": 'form',
+ "context": {
+ 'active_id': self.id,
+ 'default_message': body,
+ 'default_recipients': self.mobile_phone,
+ },
+ "name": "Send SMS",
+ "target": "new",
+ }
+
+ def action_send_mail(self):
+ self.ensure_one()
+ if not self.env.user.has_group('hr.group_hr_manager'):
+ raise UserError(_("You don't have the right to do this. Please contact an Administrator."))
+ if not self.work_email:
+ raise UserError(_("There is no professional email address for this employee."))
+ template = self.env.ref('hr_presence.mail_template_presence', False)
+ compose_form = self.env.ref('mail.email_compose_message_wizard_form', False)
+ ctx = dict(
+ default_model="hr.employee",
+ default_res_id=self.id,
+ default_use_template=bool(template),
+ default_template_id=template.id,
+ default_composition_mode='comment',
+ default_is_log=True,
+ custom_layout='mail.mail_notification_light',
+ email_from=self.env.user.email,
+ email_to=self.work_email,
+ )
+ return {
+ 'name': _('Compose Email'),
+ 'type': 'ir.actions.act_window',
+ 'view_type': 'form',
+ 'view_mode': 'form',
+ 'res_model': 'mail.compose.message',
+ 'views': [(compose_form.id, 'form')],
+ 'view_id': compose_form.id,
+ 'target': 'new',
+ 'context': ctx,
+ }
diff --git a/addons/hr_presence/models/res_company.py b/addons/hr_presence/models/res_company.py
new file mode 100644
index 00000000000..7de70604a92
--- /dev/null
+++ b/addons/hr_presence/models/res_company.py
@@ -0,0 +1,13 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models
+
+
+class ResCompany(models.Model):
+ _inherit = 'res.company'
+
+
+ hr_presence_control_email_amount = fields.Integer(string="# emails to send")
+ hr_presence_control_ip_list = fields.Char(string="Valid IP addresses")
+ hr_presence_last_compute_date = fields.Datetime()
diff --git a/addons/hr_presence/models/res_config_settings.py b/addons/hr_presence/models/res_config_settings.py
new file mode 100644
index 00000000000..e831d7506bb
--- /dev/null
+++ b/addons/hr_presence/models/res_config_settings.py
@@ -0,0 +1,13 @@
+# -*- coding: utf-8 -*-
+
+from odoo import fields, models
+
+
+class ResConfigSettings(models.TransientModel):
+ _inherit = 'res.config.settings'
+
+ hr_presence_control_login = fields.Boolean(string="According to the system login (User status on chat)", config_parameter='hr_presence.hr_presence_control_login')
+ hr_presence_control_email = fields.Boolean(string="According to the amount of sent emails", config_parameter='hr_presence.hr_presence_control_email')
+ hr_presence_control_ip = fields.Boolean(string="According to the IP address", config_parameter='hr_presence.hr_presence_control_ip')
+ hr_presence_control_email_amount = fields.Integer(related="company_id.hr_presence_control_email_amount", readonly=False)
+ hr_presence_control_ip_list = fields.Char(related="company_id.hr_presence_control_ip_list", readonly=False)
diff --git a/addons/hr_presence/models/res_users_log.py b/addons/hr_presence/models/res_users_log.py
new file mode 100644
index 00000000000..9ad3c97df6e
--- /dev/null
+++ b/addons/hr_presence/models/res_users_log.py
@@ -0,0 +1,10 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models
+
+
+class ResUsersLog(models.Model):
+ _inherit = 'res.users.log'
+
+ create_uid = fields.Integer(index=True)
+ ip = fields.Char(string="IP Address")
diff --git a/addons/hr_presence/views/hr_employee_views.xml b/addons/hr_presence/views/hr_employee_views.xml
new file mode 100644
index 00000000000..75d468ded22
--- /dev/null
+++ b/addons/hr_presence/views/hr_employee_views.xml
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <record id="hr_employee_view_search" model="ir.ui.view">
+ <field name="name">hr.employee.view.search</field>
+ <field name="model">hr.employee</field>
+ <field name="inherit_id" ref="hr.view_employee_filter"/>
+ <field name="groups_id" eval="[(4, ref('hr.group_hr_manager'))]"/>
+ <field name="arch" type="xml">
+ <filter name="group_manager" position="after">
+ <filter name="group_hr_presence_state" string="Presence" domain="[]" context="{'group_by':'hr_presence_state'}"/>
+ </filter>
+ </field>
+ </record>
+
+ <record id="hr_employee_view_kanban" model="ir.ui.view">
+ <field name="name">hr.employee.view.kanban</field>
+ <field name="model">hr.employee</field>
+ <field name="inherit_id" ref="hr.hr_kanban_view_employees"/>
+ <field name="groups_id" eval="[(4, ref('hr.group_hr_manager'))]"/>
+ <field name="mode">primary</field>
+ <field name="arch" type="xml">
+ <xpath expr="//div[hasclass('oe_kanban_details')]" position="inside">
+ <div class="o_kanban_record_bottom">
+ <div class="oe_kanban_bottom_left">
+ <field name="hr_presence_state" invisible="1"/>
+ <button name="action_set_present" type="object" class="btn btn-primary btn-sm mt8 fa fa-check" title="Set as present" attrs="{'invisible': [('hr_presence_state', '!=', 'to_define')]}"> </button>
+ <button name="action_open_leave_request" type="object" class="btn btn-primary btn-sm mt8">Leaves</button>
+ <button name="action_send_sms" type="object" class="btn btn-primary btn-sm mt8">SMS</button>
+ <button name="action_send_mail" type="object" class="btn btn-primary btn-sm mt8">Mail</button>
+ </div>
+ </div>
+ </xpath>
+ </field>
+ </record>
+</odoo>
diff --git a/addons/hr_presence/views/res_config_settings_views.xml b/addons/hr_presence/views/res_config_settings_views.xml
new file mode 100644
index 00000000000..b645cf0f7aa
--- /dev/null
+++ b/addons/hr_presence/views/res_config_settings_views.xml
@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <record id="res_config_settings_view_form" model="ir.ui.view">
+ <field name="name">res.config.settings.view.form.inherit.hr</field>
+ <field name="model">res.config.settings</field>
+ <field name="priority" eval="70"/>
+ <field name="inherit_id" ref="hr.res_config_settings_view_form"/>
+ <field name="arch" type="xml">
+ <xpath expr="//div[@data-key='hr']//div[hasclass('o_settings_container')]" position="inside">
+ <div class="col-12 col-lg-6 o_setting_box" title="Control presence of employees">
+ <div class="o_setting_left_pane">
+ <field name="module_hr_presence"/>
+ </div>
+ <div class="o_setting_right_pane">
+ <label for="module_hr_presence"/>
+ <div class="content-group">
+ <div class="row">
+ <field name="hr_presence_control_login" class="col-lg-1 ml16"/>
+ <label for="hr_presence_control_login" class="col-lg-10 o_light_label"/>
+ </div>
+ <div class="row">
+ <field name="hr_presence_control_email" class="col-lg-1 ml16"/>
+ <label for="hr_presence_control_email" class="col-lg-10 o_light_label"/>
+ </div>
+ <div attrs="{'invisible': [('hr_presence_control_email', '=', False)]}">
+ <span>At least </span>
+ <field name="hr_presence_control_email_amount"/>
+ <span> sent emails</span>
+ </div>
+ <div class="row">
+ <field name="hr_presence_control_ip" class="col-lg-1 ml16"/>
+ <label for="hr_presence_control_ip" class="col-lg-10 o_light_label"/>
+ </div>
+ <div attrs="{'invisible': [('hr_presence_control_ip', '=', False)]}">
+ <span>IP List (comma separated):</span>
+ <field name="hr_presence_control_ip_list"/>
+ </div>
+ </div>
+ </div>
+ </div>
+ </xpath>
+ </field>
+ </record>
+</odoo>
diff --git a/addons/hr_recruitment/models/hr_job.py b/addons/hr_recruitment/models/hr_job.py
index 7f1510c19c3..1a73bab1f84 100644
--- a/addons/hr_recruitment/models/hr_job.py
+++ b/addons/hr_recruitment/models/hr_job.py
@@ -69,7 +69,7 @@ class Job(models.Model):
@api.multi
def _track_subtype(self, init_values):
if 'state' in init_values and self.state == 'open':
- return 'hr_recruitment.mt_job_new'
+ return self.env.ref('hr_recruitment.mt_job_new')
return super(Job, self)._track_subtype(init_values)
@api.multi
diff --git a/addons/hr_recruitment/models/hr_recruitment.py b/addons/hr_recruitment/models/hr_recruitment.py
index 7f146ca5d56..d52e0bb3a45 100644
--- a/addons/hr_recruitment/models/hr_recruitment.py
+++ b/addons/hr_recruitment/models/hr_recruitment.py
@@ -346,11 +346,11 @@ class Applicant(models.Model):
def _track_subtype(self, init_values):
record = self[0]
if 'emp_id' in init_values and record.emp_id and record.emp_id.active:
- return 'hr_recruitment.mt_applicant_hired'
+ return self.env.ref('hr_recruitment.mt_applicant_hired')
elif 'stage_id' in init_values and record.stage_id and record.stage_id.sequence <= 1:
- return 'hr_recruitment.mt_applicant_new'
+ return self.env.ref('hr_recruitment.mt_applicant_new')
elif 'stage_id' in init_values and record.stage_id and record.stage_id.sequence > 1:
- return 'hr_recruitment.mt_applicant_stage_changed'
+ return self.env.ref('hr_recruitment.mt_applicant_stage_changed')
return super(Applicant, self)._track_subtype(init_values)
@api.multi
@@ -419,7 +419,7 @@ class Applicant(models.Model):
contact_name = False
if applicant.partner_id:
address_id = applicant.partner_id.address_get(['contact'])['contact']
- contact_name = applicant.partner_id.name_get()[0][1]
+ contact_name = applicant.partner_id.display_name
else :
new_partner_id = self.env['res.partner'].create({
'is_company': False,
diff --git a/addons/hr_recruitment/views/hr_recruitment_views.xml b/addons/hr_recruitment/views/hr_recruitment_views.xml
index 5fa6424e959..7e2570ee85b 100644
--- a/addons/hr_recruitment/views/hr_recruitment_views.xml
+++ b/addons/hr_recruitment/views/hr_recruitment_views.xml
@@ -201,7 +201,7 @@
<filter string="Unread Messages" name="message_needaction" domain="[('message_needaction','=',True)]"/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
<field name="job_id"/>
- <field name="department_id"/>
+ <field name="department_id" operator="child_of"/>
<field name="user_id"/>
<field name="stage_id" domain="[]"/>
<separator/>
@@ -690,18 +690,19 @@
<field name="model">hr.recruitment.stage</field>
<field name="arch" type="xml">
<form string="Stage">
- <group string="Stage Definition">
+ <sheet>
+ <group name="stage_definition" string="Stage Definition">
<group>
<field name="name"/>
<field name="sequence" groups="base.group_no_one"/>
<field name="job_id"/>
</group>
- <group>
- <field name="fold"/>
+ <group name="stage_details">
+ <field name="fold"/>
<field name="template_id" domain= "[('model_id.model', '=', 'hr.applicant')]"/>
</group>
</group>
- <group string="Tooltips">
+ <group name="tooltips" string="Tooltips">
<p class="text-muted" colspan="2">
You can define here the labels that will be displayed for the kanban state instead
of the default labels.
@@ -715,6 +716,7 @@
</group>
<separator string="Requirements"/>
<field name="requirements"/>
+ </sheet>
</form>
</field>
</record>
@@ -750,10 +752,12 @@
<field name="model">hr.applicant.category</field>
<field name="arch" type="xml">
<form string="Tags">
+ <sheet>
<group>
<field name="name"/>
<field name="color"/>
</group>
+ </sheet>
</form>
</field>
</record>
@@ -794,10 +798,12 @@
<field name="model">hr.recruitment.degree</field>
<field name="arch" type="xml">
<form string="Degree">
+ <sheet>
<group>
<field name="name"/>
<field name="sequence" groups="base.group_no_one"/>
</group>
+ </sheet>
</form>
</field>
</record>
@@ -882,7 +888,7 @@
<field name="name">hr.applicant.pivot</field>
<field name="model">hr.applicant</field>
<field name="arch" type="xml">
- <pivot string="Recruitment Analysis" disable_linking="True">
+ <pivot string="Recruitment Analysis">
<field name="stage_id" type="row"/>
<field name="job_id" type="col"/>
</pivot>
@@ -907,7 +913,7 @@
<field name="arch" type="xml">
<search string="Recruitment Analysis">
<field name="job_id"/>
- <field name="department_id"/>
+ <field name="department_id" operator="child_of"/>
<field name="user_id"/>
<filter string="Creation Date" name="year" date="create_date" default_period="this_year"/>
<separator/>
diff --git a/addons/hr_recruitment_survey/data/survey_survey_data.xml b/addons/hr_recruitment_survey/data/survey_survey_data.xml
index d9228f4fb47..c12284cd20b 100644
--- a/addons/hr_recruitment_survey/data/survey_survey_data.xml
+++ b/addons/hr_recruitment_survey/data/survey_survey_data.xml
@@ -20,13 +20,13 @@
<field name="page_id" ref="recruitment_1" />
<field name="sequence">1</field>
<field name="question">From which university will you graduate?</field>
- <field name="type">textbox</field>
+ <field name="question_type">textbox</field>
</record>
<record id="recruitment_1_2" model="survey.question">
<field name="page_id" ref="recruitment_1" />
<field name="sequence">2</field>
<field name="question">What is your gender?</field>
- <field name="type">simple_choice</field>
+ <field name="question_type">simple_choice</field>
<field name="display_mode">dropdown</field>
</record>
<record id="recruitment_1_2_1" model="survey.label">
@@ -43,7 +43,7 @@
<field name="page_id" ref="recruitment_1" />
<field name="sequence">3</field>
<field name="question">What age group do you belong to?</field>
- <field name="type">simple_choice</field>
+ <field name="question_type">simple_choice</field>
<field name="display_mode">columns</field>
<field name="column_nb">4</field>
</record>
@@ -99,25 +99,25 @@
<field name="page_id" ref="recruitment_2" />
<field name="sequence">1</field>
<field name="question">Knowledge</field>
- <field name="type">free_text</field>
+ <field name="question_type">free_text</field>
</record>
<record id="recruitment_2_2" model="survey.question">
<field name="page_id" ref="recruitment_2" />
<field name="sequence">2</field>
<field name="question">Education</field>
- <field name="type">free_text</field>
+ <field name="question_type">free_text</field>
</record>
<record id="recruitment_2_3" model="survey.question">
<field name="page_id" ref="recruitment_2" />
<field name="sequence">3</field>
<field name="question">Experience</field>
- <field name="type">free_text</field>
+ <field name="question_type">free_text</field>
</record>
<record id="recruitment_2_4" model="survey.question">
<field name="page_id" ref="recruitment_2" />
<field name="sequence">4</field>
<field name="question">Activities</field>
- <field name="type">free_text</field>
+ <field name="question_type">free_text</field>
</record>
<!-- Page 3 -->
<record id="recruitment_3" model="survey.page">
@@ -130,7 +130,7 @@
<field name="page_id" ref="recruitment_3" />
<field name="sequence">1</field>
<field name="question">Rate the Importance</field>
- <field name="type">matrix</field>
+ <field name="question_type">matrix</field>
<field name="matrix_subtype">simple</field>
</record>
<record id="rcol_3_1_1" model="survey.label">
diff --git a/addons/hr_timesheet/data/hr_timesheet_data.xml b/addons/hr_timesheet/data/hr_timesheet_data.xml
index 5bbea815d2c..2ae8070b787 100644
--- a/addons/hr_timesheet/data/hr_timesheet_data.xml
+++ b/addons/hr_timesheet/data/hr_timesheet_data.xml
@@ -18,7 +18,7 @@
</record>
<!-- Force Analytic account creation for projects allowing timesheet (default is True) -->
- <function id="_init_data_analytic_account"
+ <function
model="project.project"
name="_init_data_analytic_account"
eval="[]"/>
diff --git a/addons/hr_timesheet/models/project.py b/addons/hr_timesheet/models/project.py
index e93a3c5deac..f82c68fbda8 100644
--- a/addons/hr_timesheet/models/project.py
+++ b/addons/hr_timesheet/models/project.py
@@ -8,10 +8,10 @@ from odoo.exceptions import UserError, ValidationError
class Project(models.Model):
_inherit = "project.project"
- allow_timesheets = fields.Boolean("Allow timesheets", default=True)
+ allow_timesheets = fields.Boolean("Allow timesheets", default=True, help="Enable timesheeting on the project.")
analytic_account_id = fields.Many2one('account.analytic.account', string="Analytic Account", copy=False, ondelete='set null',
- help="Link this project to an analytic account if you need financial management on projects. "
- "It enables you to connect projects with budgets, planning, cost and revenue analysis, timesheets on projects, etc.")
+ help="Analytic account to which this project is linked for financial management."
+ "Use an analytic account to record cost and revenue on your project.")
@api.onchange('partner_id')
def _onchange_partner_id(self):
diff --git a/addons/iap/__manifest__.py b/addons/iap/__manifest__.py
index 6a30ed891c9..02ec21c6c68 100644
--- a/addons/iap/__manifest__.py
+++ b/addons/iap/__manifest__.py
@@ -16,6 +16,9 @@ This module provides standard tools (account model, context manager and helpers)
'views/assets.xml',
'views/iap_views.xml',
],
+ 'images': [
+ 'static/src/img/iap_logo.svg',
+ ],
'qweb': [
'static/src/xml/iap_templates.xml',
],
diff --git a/addons/iap/static/src/img/iap_logo.svg b/addons/iap/static/src/img/iap_logo.svg
new file mode 100644
index 00000000000..d7875e2632c
--- /dev/null
+++ b/addons/iap/static/src/img/iap_logo.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="680" height="143"><g fill="none" fill-rule="evenodd"><g fill-rule="nonzero"><path fill="#888" d="M397.168 143.475c-30.083 0-54.47-24.387-54.47-54.47 0-30.084 24.387-54.472 54.47-54.472 30.084 0 54.471 24.388 54.471 54.471 0 30.084-24.387 54.471-54.47 54.471zm0-22.365c17.732 0 32.106-14.375 32.106-32.106S414.9 56.9 397.168 56.9c-17.73 0-32.105 14.374-32.105 32.105 0 17.731 14.374 32.106 32.105 32.106zm-115.435 22.365c-30.083 0-54.47-24.387-54.47-54.47 0-30.084 24.387-54.472 54.47-54.472 30.084 0 54.471 24.388 54.471 54.471 0 30.084-24.387 54.471-54.47 54.471zm0-22.365c17.732 0 32.106-14.375 32.106-32.106S299.465 56.9 281.733 56.9c-17.73 0-32.105 14.374-32.105 32.105 0 17.731 14.374 32.106 32.105 32.106z"/><path fill="#888" d="M222.306 88.575l.001.43c0 30.083-24.387 54.47-54.47 54.47-30.084 0-54.471-24.387-54.471-54.47 0-30.084 24.387-54.472 54.47-54.472a54.23 54.23 0 0 1 32.84 11.009V10.825C200.676 4.847 205.52 0 211.498 0c5.977 0 10.822 4.851 10.822 10.825v77.191c0 .188-.005.374-.014.559zm-54.47 32.535c17.732 0 32.106-14.375 32.106-32.106S185.568 56.9 167.837 56.9c-17.732 0-32.106 14.374-32.106 32.105 0 17.731 14.374 32.106 32.106 32.106z"/><path fill="#9C5789" d="M54.97 143.354C24.887 143.354.5 118.967.5 88.884.5 58.8 24.887 34.411 54.97 34.411c30.084 0 54.471 24.388 54.471 54.471 0 30.084-24.387 54.471-54.47 54.471zm-.5-22.147c17.732 0 32.106-14.374 32.106-32.106 0-17.73-14.374-32.105-32.105-32.105-17.731 0-32.105 14.374-32.105 32.105 0 17.732 14.374 32.106 32.105 32.106z"/></g><text fill="#8F8F8F" font-family="Helvetica" font-size="134" letter-spacing="-4.438"><tspan x="478" y="143">IAP</tspan></text></g></svg> \ No newline at end of file
diff --git a/addons/iap/static/src/js/dashboard.js b/addons/iap/static/src/js/dashboard.js
index 995cc444e24..ce8939ad282 100644
--- a/addons/iap/static/src/js/dashboard.js
+++ b/addons/iap/static/src/js/dashboard.js
@@ -2,23 +2,41 @@ odoo.define('iap.Dashboard', function (require) {
"use strict";
var Dashboard = require('web_settings_dashboard');
+var Widget = require('web.Widget');
Dashboard.Dashboard.include({
/**
* @override
*/
- load_apps: function (data) {
- var _super = this._super.bind(this);
+ init: function () {
+ this._super.apply(this, arguments);
+ return this.all_dashboards.push('iap');
+ },
+
+ load_iap: function (data) {
+ var self = this;
return this._rpc({
model: 'iap.account',
method: 'get_account_url',
args: [],
kwargs: {},
}).then(function (url) {
- data.apps.url = url;
- return _super(data);
+ data.iap = {};
+ data.iap.url = url;
+ return new DashboardIAP(self, data.iap).replace(self.$('.o_web_settings_dashboard_iap'));
});
},
});
+var DashboardIAP = Widget.extend({
+ template: 'iap.web_settings_dashboard_iap',
+
+ init: function(parent, data) {
+ this.data = data;
+ this.parent = parent;
+ return this._super.apply(this, arguments);
+ },
+
+});
+
});
diff --git a/addons/iap/static/src/xml/iap_templates.xml b/addons/iap/static/src/xml/iap_templates.xml
index d564c755876..10969dddd3d 100644
--- a/addons/iap/static/src/xml/iap_templates.xml
+++ b/addons/iap/static/src/xml/iap_templates.xml
@@ -16,12 +16,24 @@
</t>
</div>
- <t t-extend="DashboardApps">
- <t t-jquery=".o_web_settings_dashboard_pills > :first-child" t-operation="after">
- <a t-att-href="widget.data.url" class="d-block flex-grow-1 w-25" target="_blank"><i class="fa fa-lg fa-money text-muted"/> In-App Purchases</a>
+ <t t-extend="DashboardMain">
+ <t t-jquery=".o_web_settings_dashboard_apps" t-operation="after">
+ <div class="o_web_settings_dashboard_iap"></div>
</t>
</t>
+ <t t-name="iap.web_settings_dashboard_iap">
+ <div class="text-center o_web_settings_dashboard_iap mt8">
+ <img class="col-5" src="/iap/static/src/img/iap_logo.svg"/>
+ <div class="o_web_settings_dashboard_header mt8">
+ In-App Purchases
+ </div>
+ <div class="mt16">
+ <a t-att-href="widget.data.url" role="button" class="btn btn-primary btn-block o_go_to_portal"><strong>Go to My Portal</strong></a>
+ </div>
+ </div>
+ </t>
+
<div t-name="iap.credit_checker" class="mt-2 row">
<div class="col-sm">
<div class="loading text-muted">
diff --git a/addons/l10n_ch/models/res_config_settings.py b/addons/l10n_ch/models/res_config_settings.py
index 0b972855e66..e8415164d88 100644
--- a/addons/l10n_ch/models/res_config_settings.py
+++ b/addons/l10n_ch/models/res_config_settings.py
@@ -18,3 +18,4 @@ class ResConfigSettings(models.TransientModel):
related="company_id.l10n_ch_isr_scan_line_left", readonly=False)
l10n_ch_isr_scan_line_top = fields.Float(string='Vertical offset',
related="company_id.l10n_ch_isr_scan_line_top", readonly=False)
+ l10n_ch_print_qrcode = fields.Boolean("Print Swiss QR Code", config_parameter='l10n_ch.print_qrcode')
diff --git a/addons/l10n_ch/views/res_config_settings_views.xml b/addons/l10n_ch/views/res_config_settings_views.xml
index 5f95af1b3d2..bfe598cffe3 100644
--- a/addons/l10n_ch/views/res_config_settings_views.xml
+++ b/addons/l10n_ch/views/res_config_settings_views.xml
@@ -9,6 +9,15 @@
<xpath expr="//div[@id='invoicing_settings']" position="inside">
<div class="col-12 col-lg-6 o_setting_box" id="l10n_ch-isr_print_bank">
<div class="o_setting_left_pane">
+ <field name="l10n_ch_print_qrcode"/>
+ </div>
+ <div class="o_setting_right_pane">
+ <label for="l10n_ch_print_qrcode"/>
+ <div class="text-muted">
+ Append the QR Code ISR at the end of customer invoices for Swiss customers
+ </div>
+ </div>
+ <div class="o_setting_left_pane">
<field name="l10n_ch_isr_print_bank_location"/>
</div>
<div class="o_setting_right_pane">
diff --git a/addons/link_tracker/__init__.py b/addons/link_tracker/__init__.py
index c57bb82153a..faf00003256 100644
--- a/addons/link_tracker/__init__.py
+++ b/addons/link_tracker/__init__.py
@@ -1,3 +1,5 @@
# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
from . import models
-from . import controller \ No newline at end of file
+from . import controller
diff --git a/addons/link_tracker/__manifest__.py b/addons/link_tracker/__manifest__.py
index 206fbd438d7..c458d23f539 100644
--- a/addons/link_tracker/__manifest__.py
+++ b/addons/link_tracker/__manifest__.py
@@ -1,15 +1,16 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
{
'name': 'Link Tracker',
'category': 'Marketing',
'description': """
-Create short and trackable URLs.
-=====================================================
-
- """,
+Shorten URLs and use them to track clicks and UTMs
+""",
'version': '1.0',
'depends': ['utm'],
'data': [
- 'views/link_tracker.xml',
+ 'views/link_tracker_views.xml',
'security/ir.model.access.csv',
],
}
diff --git a/addons/link_tracker/controller/__init__.py b/addons/link_tracker/controller/__init__.py
index 757b12a1f17..5d4b25db9c0 100644
--- a/addons/link_tracker/controller/__init__.py
+++ b/addons/link_tracker/controller/__init__.py
@@ -1,2 +1,4 @@
# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
from . import main
diff --git a/addons/link_tracker/models/__init__.py b/addons/link_tracker/models/__init__.py
index 6436fc3a4ba..d0881ab6f62 100644
--- a/addons/link_tracker/models/__init__.py
+++ b/addons/link_tracker/models/__init__.py
@@ -1,2 +1,4 @@
# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
from . import link_tracker
diff --git a/addons/link_tracker/models/link_tracker.py b/addons/link_tracker/models/link_tracker.py
index ae52a6966f2..c0147fe0e56 100644
--- a/addons/link_tracker/models/link_tracker.py
+++ b/addons/link_tracker/models/link_tracker.py
@@ -1,21 +1,22 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
import base64
import datetime
import random
import re
+import requests
import string
-import requests
from lxml import html
from werkzeug import urls, utils
-
from odoo import models, fields, api, _
-from odoo.tools import ustr
+
URL_REGEX = r'(\bhref=[\'"](?!mailto:|tel:|sms:)([^\'"]+)[\'"])'
+
def VALIDATE_URL(url):
if urls.url_parse(url).scheme not in ('http', 'https', 'ftp', 'ftps'):
return 'http://' + url
@@ -23,50 +24,32 @@ def VALIDATE_URL(url):
return url
-class link_tracker(models.Model):
- """link_tracker allow users to wrap any URL into a short and trackable URL.
- link_tracker counts clicks on each tracked link.
- This module is also used by mass_mailing, where each link in mail_mail html_body are converted into
- a trackable link to get the click-through rate of each mass_mailing."""
+class LinkTracker(models.Model):
+ """ Link trackers allow users to wrap any URL into a short URL that can be
+ tracked by Odoo. Clicks are counter on each link. A tracker is linked to
+ UTMs allowing to analyze marketing actions.
+ This model is also used in mass_mailing where each link in html body is
+ automatically converted into a short link that is tracked and integrates
+ UTMs. """
_name = "link.tracker"
_rec_name = "short_url"
- _description = 'Link Tracker'
-
- _inherit = ['utm.mixin']
+ _description = "Link Tracker"
+ _inherit = ["utm.mixin"]
+ # URL info
url = fields.Char(string='Target URL', required=True)
- count = fields.Integer(string='Number of Clicks', compute='_compute_count', store=True)
short_url = fields.Char(string='Tracked URL', compute='_compute_short_url')
- link_click_ids = fields.One2many('link.tracker.click', 'link_id', string='Clicks')
+ redirected_url = fields.Char(string='Redirected URL', compute='_compute_redirected_url')
+ short_url_host = fields.Char(string='Host of the short URL', compute='_compute_short_url_host')
title = fields.Char(string='Page Title', store=True)
favicon = fields.Char(string='Favicon', compute='_compute_favicon', store=True)
+ icon_src = fields.Char(string='Favicon Source', compute='_compute_icon_src')
+ # Tracking
link_code_ids = fields.One2many('link.tracker.code', 'link_id', string='Codes')
code = fields.Char(string='Short URL code', compute='_compute_code')
- redirected_url = fields.Char(string='Redirected URL', compute='_compute_redirected_url')
- short_url_host = fields.Char(string='Host of the short URL', compute='_compute_short_url_host')
- icon_src = fields.Char(string='Favicon Source', compute='_compute_icon_src')
-
- @api.model
- def convert_links(self, html, vals, blacklist=None):
- for match in re.findall(URL_REGEX, html):
-
- short_schema = self.env['ir.config_parameter'].sudo().get_param('web.base.url') + '/r/'
-
- href = match[0]
- long_url = match[1]
-
- vals['url'] = utils.unescape(long_url)
-
- if not blacklist or not [s for s in blacklist if s in long_url] and not long_url.startswith(short_schema):
- link = self.create(vals)
- shorten_url = self.browse(link.id)[0].short_url
-
- if shorten_url:
- new_href = href.replace(long_url, shorten_url)
- html = html.replace(href, new_href)
-
- return html
+ link_click_ids = fields.One2many('link.tracker.click', 'link_id', string='Clicks')
+ count = fields.Integer(string='Number of Clicks', compute='_compute_count', store=True)
@api.one
@api.depends('link_click_ids.link_id')
@@ -130,32 +113,6 @@ class link_tracker(models.Model):
self.favicon = icon_base64
- @api.multi
- def action_view_statistics(self):
- action = self.env['ir.actions.act_window'].for_xml_id('link_tracker', 'action_view_click_statistics')
- action['domain'] = [('link_id', '=', self.id)]
- return action
-
- @api.multi
- def action_visit_page(self):
- return {
- 'name': _("Visit Webpage"),
- 'type': 'ir.actions.act_url',
- 'url': self.url,
- 'target': 'new',
- }
-
- @api.model
- def recent_links(self, filter, limit):
- if filter == 'newest':
- return self.search_read([], order='create_date DESC', limit=limit)
- elif filter == 'most-clicked':
- return self.search_read([('count', '!=', 0)], order='count DESC', limit=limit)
- elif filter == 'recently-used':
- return self.search_read([('count', '!=', 0)], order='write_date DESC', limit=limit)
- else:
- return {'Error': "This filter doesn't exist."}
-
@api.model
def create(self, vals):
create_vals = vals.copy()
@@ -182,7 +139,7 @@ class link_tracker(models.Model):
if fname not in create_vals:
create_vals[fname] = False
- link = super(link_tracker, self).create(create_vals)
+ link = super(LinkTracker, self).create(create_vals)
code = self.env['link.tracker.code'].get_random_code_string()
self.env['link.tracker.code'].sudo().create({'code': code, 'link_id': link.id})
@@ -190,6 +147,53 @@ class link_tracker(models.Model):
return link
@api.model
+ def convert_links(self, html, vals, blacklist=None):
+ for match in re.findall(URL_REGEX, html):
+
+ short_schema = self.env['ir.config_parameter'].sudo().get_param('web.base.url') + '/r/'
+
+ href = match[0]
+ long_url = match[1]
+
+ vals['url'] = utils.unescape(long_url)
+
+ if not blacklist or not [s for s in blacklist if s in long_url] and not long_url.startswith(short_schema):
+ link = self.create(vals)
+ shorten_url = self.browse(link.id)[0].short_url
+
+ if shorten_url:
+ new_href = href.replace(long_url, shorten_url)
+ html = html.replace(href, new_href)
+
+ return html
+
+ @api.multi
+ def action_view_statistics(self):
+ action = self.env['ir.actions.act_window'].for_xml_id('link_tracker', 'link_tracker_click_action_statistics')
+ action['domain'] = [('link_id', '=', self.id)]
+ return action
+
+ @api.multi
+ def action_visit_page(self):
+ return {
+ 'name': _("Visit Webpage"),
+ 'type': 'ir.actions.act_url',
+ 'url': self.url,
+ 'target': 'new',
+ }
+
+ @api.model
+ def recent_links(self, filter, limit):
+ if filter == 'newest':
+ return self.search_read([], order='create_date DESC', limit=limit)
+ elif filter == 'most-clicked':
+ return self.search_read([('count', '!=', 0)], order='count DESC', limit=limit)
+ elif filter == 'recently-used':
+ return self.search_read([('count', '!=', 0)], order='write_date DESC', limit=limit)
+ else:
+ return {'Error': "This filter doesn't exist."}
+
+ @api.model
def get_url_from_code(self, code, context=None):
code_rec = self.env['link.tracker.code'].sudo().search([('code', '=', code)])
@@ -203,13 +207,17 @@ class link_tracker(models.Model):
]
-class link_tracker_code(models.Model):
+class LinkTrackerCode(models.Model):
_name = "link.tracker.code"
- _description = 'Link Tracker Code'
+ _description = "Link Tracker Code"
code = fields.Char(string='Short URL Code', store=True)
link_id = fields.Many2one('link.tracker', 'Link', required=True, ondelete='cascade')
+ _sql_constraints = [
+ ('code', 'unique( code )', 'Code must be unique.')
+ ]
+
@api.model
def get_random_code_string(self):
size = 3
@@ -221,18 +229,14 @@ class link_tracker_code(models.Model):
else:
return code_proposition
- _sql_constraints = [
- ('code', 'unique( code )', 'Code must be unique.')
- ]
-
-class link_tracker_click(models.Model):
+class LinkTrackerClick(models.Model):
_name = "link.tracker.click"
_rec_name = "link_id"
- _description = 'Link Tracker Click'
+ _description = "Link Tracker Click"
- click_date = fields.Date(string='Create Date')
link_id = fields.Many2one('link.tracker', 'Link', required=True, ondelete='cascade')
+ click_date = fields.Date(string='Create Date')
ip = fields.Char(string='Internet Protocol')
country_id = fields.Many2one('res.country', 'Country')
diff --git a/addons/link_tracker/views/link_tracker.xml b/addons/link_tracker/views/link_tracker_views.xml
index 951bc2d8154..0ed9fe524c4 100644
--- a/addons/link_tracker/views/link_tracker.xml
+++ b/addons/link_tracker/views/link_tracker_views.xml
@@ -1,28 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
- <record model="ir.actions.act_window" id="action_view_click_statistics">
- <field name="name">Statistics of Clicks</field>
- <field name="res_model">link.tracker.click</field>
- <field name="view_mode">graph</field>
- <field name="domain">[('link_id', 'IN', ids)]</field>
- </record>
-
- <!-- Search -->
- <record id="view_link_tracker_filter" model="ir.ui.view">
- <field name="name">Links</field>
+ <!-- LINT.TRACKER -->
+ <record id="link_tracker_view_search" model="ir.ui.view">
+ <field name="name">link.tracker.view.search</field>
<field name="model">link.tracker</field>
<field name="arch" type="xml">
- <search string="Tracked Link">
- <field name="title"/>
+ <search string="Links">
<field name="url" string="Title and URL" filter_domain="['|', ('title', 'ilike', self), ('url', 'ilike', self)]"/>
+ <field name="title"/>
</search>
</field>
</record>
- <!-- Website links views -->
- <record id="view_link_tracker_form" model="ir.ui.view">
- <field name="name">link.tracker.form</field>
+ <record id="link_tracker_view_form" model="ir.ui.view">
+ <field name="name">link.tracker.view.form</field>
<field name="model">link.tracker</field>
<field name="arch" type="xml">
<form string="Website Link">
@@ -34,56 +26,29 @@
<button type="object" class="oe_stat_button" name="action_view_statistics" icon="fa-bar-chart-o">
<field name="count" string="Clicks" widget="statinfo"/>
</button>
-
</div>
<group>
- <field name="title"/>
- <field name="url"/>
- <field name="short_url"/>
+ <group name="url" string="URL">
+ <field name="title"/>
+ <field name="url"/>
+ <field name="short_url"/>
+ </group>
+ <group name="utm" string="UTM">
+ <field name="campaign_id"/>
+ <field name="medium_id"/>
+ <field name="source_id"/>
+ </group>
</group>
-
- <group>
- <field name="campaign_id"/>
- <field name="medium_id"/>
- <field name="source_id"/>
- </group>
- </sheet>
- </form>
- </field>
- </record>
-
-
- <record id="view_link_tracker_form_stats" model="ir.ui.view">
- <field name="name">link.tracker.my.form</field>
- <field name="model">link.tracker</field>
- <field name="arch" type="xml">
- <form string="Website Link Graph">
- <sheet>
- <field name="link_click_ids">
- <graph>
- <field name="link_id"/>
- <field name="ip"/>
- <field name="country_id"/>
- </graph>
- </field>
</sheet>
</form>
</field>
</record>
- <record model="ir.actions.act_window" id="action_link_tracker_stats">
- <field name="name">link.tracker.form.graph.action</field>
- <field name="res_model">link.tracker</field>
- <field name="view_type">form</field>
- <field name="view_mode">form</field>
- <field name="view_id" ref="view_link_tracker_form_stats"/>
- </record>
-
- <record id="view_link_tracker_tree" model="ir.ui.view">
- <field name="name">link.tracker.tree</field>
+ <record id="link_tracker_view_tree" model="ir.ui.view">
+ <field name="name">link.tracker.view.tree</field>
<field name="model">link.tracker</field>
<field name="arch" type="xml">
- <tree string="Website Links">
+ <tree string="Links">
<field name="create_date"/>
<field name="title"/>
<field name="url"/>
@@ -93,24 +58,23 @@
</field>
</record>
- <record id="view_link_tracker_graph" model="ir.ui.view" >
- <field name="name">link.tracker.graph</field>
+ <record id="link_tracker_view_graph" model="ir.ui.view">
+ <field name="name">link.tracker.view.graph</field>
<field name="model">link.tracker</field>
<field name="arch" type="xml">
- <graph string="Website Links" type="bar">
+ <graph string="Links" type="bar">
<field name="url" type="row"/>
<field name="count" type="measure"/>
</graph>
</field>
</record>
- <!-- Action for website links-->
- <record model="ir.actions.act_window" id="action_link_tracker">
+ <record id="link_tracker_action" model="ir.actions.act_window">
<field name="name">Link Tracker</field>
<field name="res_model">link.tracker</field>
<field name="view_type">form</field>
<field name="view_mode">tree,form,graph</field>
- <field name="view_id" ref="view_link_tracker_tree"/>
+ <field name="view_id" ref="link_tracker_view_tree"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a new link tracker
@@ -118,14 +82,12 @@
</field>
</record>
- <!-- Website links click views -->
- <record id="view_link_tracker_click_form" model="ir.ui.view">
- <field name="name">link.tracker.click.form</field>
+ <!-- LINK.TRACKER.CLICK -->
+ <record id="link_tracker_click_view_form" model="ir.ui.view">
+ <field name="name">link.tracker.click.view.form</field>
<field name="model">link.tracker.click</field>
<field name="arch" type="xml">
- <form string="Website Link Clicks">
- <header>
- </header>
+ <form string="Link Click">
<sheet>
<group>
<field name="link_id"/>
@@ -137,11 +99,11 @@
</field>
</record>
- <record id="view_link_tracker_click_tree" model="ir.ui.view">
- <field name="name">link.tracker.click.tree</field>
+ <record id="link_tracker_click_view_tree" model="ir.ui.view">
+ <field name="name">link.tracker.click.view.tree</field>
<field name="model">link.tracker.click</field>
<field name="arch" type="xml">
- <tree string="Website Links Clicks">
+ <tree string="Links Clicks">
<field name="link_id"/>
<field name="ip"/>
<field name="country_id"/>
@@ -149,11 +111,11 @@
</field>
</record>
- <record id="view_link_tracker_click_graph" model="ir.ui.view" >
- <field name="name">link.tracker.click.graph</field>
+ <record id="link_tracker_click_view_graph" model="ir.ui.view">
+ <field name="name">link.tracker.click.view.graph</field>
<field name="model">link.tracker.click</field>
<field name="arch" type="xml">
- <graph string="Website Link Clicks">
+ <graph string="Link Clicks">
<field name="link_id"/>
<field name="ip"/>
<field name="country_id"/>
@@ -161,8 +123,18 @@
</field>
</record>
- <!-- Action for website links click-->
+ <record id="link_tracker_click_action_statistics" model="ir.actions.act_window">
+ <field name="name">Click Statistics</field>
+ <field name="res_model">link.tracker.click</field>
+ <field name="view_mode">graph</field>
+ <field name="domain">[]</field>
+ </record>
- <menuitem id="menu_url_shortener_main" name="Link Tracker" parent="utm.menu_link_tracker_root" action="action_link_tracker" groups="base.group_no_one"/>
+ <!-- MENUS -->
+ <menuitem id="link_tracker_menu_main"
+ name="Link Tracker"
+ parent="utm.menu_link_tracker_root"
+ action="link_tracker_action"
+ groups="base.group_no_one"/>
</data>
</odoo>
diff --git a/addons/lunch/views/lunch_views.xml b/addons/lunch/views/lunch_views.xml
index 1c9a718095d..bdb16983772 100644
--- a/addons/lunch/views/lunch_views.xml
+++ b/addons/lunch/views/lunch_views.xml
@@ -314,9 +314,11 @@
<field name="model">lunch.product.category</field>
<field name="arch" type="xml">
<form string="Products Form">
+ <sheet>
<group>
<field name='name' string="Product Category: "/>
</group>
+ </sheet>
</form>
</field>
</record>
@@ -669,7 +671,7 @@
</button>
</div>
<group>
- <group string="Schedule">
+ <group name="schedule" string="Schedule">
<group colspan="4">
<field name="alert_type"/>
<field name="specific_day"
@@ -677,7 +679,7 @@
<field name='start_hour' widget='float_time' />
<field name='end_hour' widget='float_time' />
</group>
- <group col="4" attrs="{'invisible': [('alert_type','!=','week')]}">
+ <group name="weekdays" col="4" attrs="{'invisible': [('alert_type','!=','week')]}">
<field name="monday"/>
<field name="tuesday"/>
<field name="wednesday"/>
@@ -687,8 +689,8 @@
<field name="sunday"/>
</group>
</group>
- <group string="Vendor">
- <group colspan="4">
+ <group name="vendor" string="Vendor">
+ <group name="partner" colspan="4">
<field name="partner_id"/>
<field name="display" invisible="True"/>
</group>
diff --git a/addons/mail/data/mail_data.xml b/addons/mail/data/mail_data.xml
index ed26830a36a..9553cc5b69e 100644
--- a/addons/mail/data/mail_data.xml
+++ b/addons/mail/data/mail_data.xml
@@ -301,7 +301,7 @@
<template id="message_user_assigned">
<p style="margin: 0px;">
<span>Dear <t t-esc="object.user_id.sudo().name"/>,</span><br />
- <span style="margin-top: 8px;">You have been assigned to the <t t-esc="object._description.lower()"/> <t t-esc="object.name_get()[0][1]"/>.</span>
+ <span style="margin-top: 8px;">You have been assigned to the <t t-esc="object._description.lower()"/> <t t-esc="object.display_name"/>.</span>
</p>
<p style="margin-top: 24px; margin-bottom: 16px;">
<a t-att-href="'/mail/view?model=%s&amp;res_id=%s' % (object._name, object.id)" style="background-color:#875A7B; padding: 10px; text-decoration: none; color: #fff; border-radius: 5px;">
diff --git a/addons/mail/models/mail_activity.py b/addons/mail/models/mail_activity.py
index 9838b96a5cd..d7829ae0a1a 100644
--- a/addons/mail/models/mail_activity.py
+++ b/addons/mail/models/mail_activity.py
@@ -171,7 +171,7 @@ class MailActivity(models.Model):
@api.depends('res_model', 'res_id')
def _compute_res_name(self):
for activity in self:
- activity.res_name = self.env[activity.res_model].browse(activity.res_id).name_get()[0][1]
+ activity.res_name = self.env[activity.res_model].browse(activity.res_id).display_name
@api.depends('date_deadline')
def _compute_state(self):
diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py
index 766227d51ee..f964198d755 100644
--- a/addons/mail/models/mail_message.py
+++ b/addons/mail/models/mail_message.py
@@ -893,7 +893,7 @@ class Message(models.Model):
res_id = values.get('res_id', self.env.context.get('default_res_id'))
if not model or not res_id or model not in self.env:
return False
- return self.env[model].sudo().browse(res_id).name_get()[0][1]
+ return self.env[model].sudo().browse(res_id).display_name
@api.model
def _get_reply_to(self, values):
diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py
index bd538fe7a39..a4feb61b3fb 100644
--- a/addons/mail/models/mail_thread.py
+++ b/addons/mail/models/mail_thread.py
@@ -376,7 +376,7 @@ class MailThread(models.AbstractModel):
alias = aliases[0]
if alias:
- email_link = "<a href='mailto:%(email)s'>%(email)s</a>" % {'email': alias.name_get()[0][1]}
+ email_link = "<a href='mailto:%(email)s'>%(email)s</a>" % {'email': alias.display_name}
if nothing_here:
return "<p class='o_view_nocontent_smiling_face'>%(dyn_help)s</p>" % {
'dyn_help': _("Add a new %(document)s or send an email to %(email_link)s") % {
@@ -562,7 +562,7 @@ class MailThread(models.AbstractModel):
:param init_values: the original values of the record; only modified fields
are present in the dict
:type init_values: dict
- :returns: a subtype xml_id or False if no subtype is trigerred
+ :returns: a subtype browse record or False if no subtype is trigerred
"""
return False
@@ -635,17 +635,15 @@ class MailThread(models.AbstractModel):
continue
# find subtypes and post messages or log if no subtype found
- subtype_xmlid = False
+ subtype = False
# By passing this key, that allows to let the subtype empty and so don't sent email because partners_to_notify from mail_message._notify will be empty
if not self._context.get('mail_track_log_only'):
- subtype_xmlid = record._track_subtype(dict((col_name, initial_values[record.id][col_name]) for col_name in changes))
-
- if subtype_xmlid:
- subtype_rec = self.env.ref(subtype_xmlid) # TDE FIXME check for raise if not found
- if not (subtype_rec and subtype_rec.exists()):
- _logger.debug('subtype %s not found' % subtype_xmlid)
+ subtype = record._track_subtype(dict((col_name, initial_values[record.id][col_name]) for col_name in changes))
+ if subtype:
+ if not subtype.exists():
+ _logger.debug('subtype "%s" not found' % subtype.name)
continue
- record.message_post(subtype=subtype_xmlid, tracking_value_ids=tracking_value_ids)
+ record.message_post(subtype_id=subtype.id, tracking_value_ids=tracking_value_ids)
elif tracking_value_ids:
record._message_log(tracking_value_ids=tracking_value_ids)
diff --git a/addons/mail/models/res_users.py b/addons/mail/models/res_users.py
index 495b9ae8722..0647d3f9e6e 100644
--- a/addons/mail/models/res_users.py
+++ b/addons/mail/models/res_users.py
@@ -140,6 +140,10 @@ GROUP BY channel_moderator.res_users_id""", [tuple(self.ids)])
if activity['states'] in ('today', 'overdue'):
user_activities[activity['model']]['total_count'] += activity['count']
+ user_activities[activity['model']]['actions'] = [{
+ 'icon': 'fa-clock-o',
+ 'name': 'Summary',
+ }]
return list(user_activities.values())
diff --git a/addons/mail/static/src/js/emojis.js b/addons/mail/static/src/js/emojis.js
index c27eb1a58e7..54b995f3bb6 100644
--- a/addons/mail/static/src/js/emojis.js
+++ b/addons/mail/static/src/js/emojis.js
@@ -72,6 +72,7 @@ var data = {
"<3": "❤️",
"&lt;3": "❤️",
":heart": "❤️", // alt.
+ "</3": "💔",
":heart_eyes": "😍",
":turban": "👳",
":+1": "👍",
diff --git a/addons/mail/static/src/js/systray/systray_activity_menu.js b/addons/mail/static/src/js/systray/systray_activity_menu.js
index 4f0b5cff535..c825844851e 100644
--- a/addons/mail/static/src/js/systray/systray_activity_menu.js
+++ b/addons/mail/static/src/js/systray/systray_activity_menu.js
@@ -100,14 +100,27 @@ var ActivityMenu = Widget.extend({
//------------------------------------------------------------
/**
- * Redirect to specific action given its xml id
+ * Redirect to specific action given its xml id or to the activity
+ * view of the current model if no xml id is provided
+ *
* @private
* @param {MouseEvent} ev
*/
_onActivityActionClick: function (ev) {
ev.stopPropagation();
- var actionXmlid = $(ev.currentTarget).data('action_xmlid');
- this.do_action(actionXmlid);
+ var targetAction = $(ev.currentTarget);
+ var actionXmlid = targetAction.data('action_xmlid');
+ if (actionXmlid) {
+ this.do_action(actionXmlid);
+ } else {
+ this.do_action({
+ type: 'ir.actions.act_window',
+ name: targetAction.data('model_name'),
+ views: [[false, 'activity'], [false, 'kanban'], [false, 'list']],
+ view_mode: 'activity',
+ res_model: targetAction.data('res_model')
+ });
+ }
},
/**
diff --git a/addons/mail/static/tests/systray/systray_activity_menu_tests.js b/addons/mail/static/tests/systray/systray_activity_menu_tests.js
index 1b034dbdfd7..c666628624c 100644
--- a/addons/mail/static/tests/systray/systray_activity_menu_tests.js
+++ b/addons/mail/static/tests/systray/systray_activity_menu_tests.js
@@ -20,6 +20,11 @@ QUnit.module('ActivityMenu', {
today_count: { type: "integer"},
overdue_count: { type: "integer"},
total_count: { type: "integer"},
+ actions: [{
+ icon: { type: "char" },
+ name: { type: "char" },
+ action_xmlid: { type: "char" },
+ }],
},
records: [{
name: "Contact",
@@ -47,6 +52,24 @@ QUnit.module('ActivityMenu', {
today_count: 1,
overdue_count: 1,
total_count: 3,
+ actions : [{
+ icon: "fa-clock-o",
+ name: "summary",
+ }],
+ },
+ {
+ name: "Note",
+ type: "activity",
+ model: "partner",
+ planned_count: 1,
+ today_count: 1,
+ overdue_count: 1,
+ total_count: 3,
+ actions: [{
+ icon: "fa-clock-o",
+ name: "summary",
+ action_xmlid: "mail.mail_activity_type_view_tree",
+ }],
}],
},
};
@@ -88,7 +111,7 @@ QUnit.test('activity menu widget: activity menu with 3 records', function (asser
assert.ok(activityMenu.$el.hasClass('o_mail_systray_item'), 'should be the instance of widget');
assert.ok(activityMenu.$('.o_mail_preview').hasClass('o_mail_preview'), "should instance of widget");
assert.ok(activityMenu.$('.o_notification_counter').hasClass('o_notification_counter'), "widget should have notification counter");
- assert.strictEqual(parseInt(activityMenu.el.innerText), 5, "widget should have 5 notification counter");
+ assert.strictEqual(parseInt(activityMenu.el.innerText), 8, "widget should have 8 notification counter");
var context = {};
testUtils.intercept(activityMenu, 'do_action', function (event) {
@@ -125,5 +148,53 @@ QUnit.test('activity menu widget: activity menu with 3 records', function (asser
activityMenu.destroy();
});
+
+QUnit.test('activity menu widget: activity view icon', function (assert) {
+ assert.expect(8);
+ var self = this;
+ var activityMenu = new ActivityMenu();
+ testUtils.addMockEnvironment(activityMenu, {
+ services: this.services,
+ mockRPC: function (route, args) {
+ if (args.method === 'systray_get_activities') {
+ return $.when(self.data['mail.activity.menu'].records);
+ }
+ return this._super(route, args);
+ },
+ });
+ activityMenu.appendTo($('#qunit-fixture'));
+ assert.strictEqual(activityMenu.$('.o_mail_activity_action').length, 2,
+ "widget should have 2 activity view icons");
+
+ var $first = activityMenu.$('.o_mail_activity_action').eq(0);
+ var $second = activityMenu.$('.o_mail_activity_action').eq(1);
+ assert.strictEqual($first.data('model_name'), "Issue",
+ "first activity action should link to 'Issue'");
+ assert.ok($first.hasClass('fa-clock-o'), "should display the activity action icon");
+
+ assert.strictEqual($second.data('model_name'), "Note",
+ "Second activity action should link to 'Note'");
+ assert.ok($second.hasClass('fa-clock-o'), "should display the activity action icon");
+
+ testUtils.intercept(activityMenu, 'do_action', function (event) {
+ assert.step('do_action:' +
+ (event.data.action.name ? event.data.action.name : event.data.action));
+ }, true);
+
+ // click on the "Issue" activity icon
+ activityMenu.$('.dropdown-toggle').click();
+ activityMenu.$(".o_mail_activity_action[data-model_name='Issue']").click();
+
+ // click on the "Note" activity icon
+ activityMenu.$('.dropdown-toggle').click();
+ activityMenu.$(".o_mail_activity_action[data-model_name='Note']").click();
+
+ assert.verifySteps([
+ 'do_action:Issue',
+ 'do_action:mail.mail_activity_type_view_tree'
+ ]);
+
+ activityMenu.destroy();
+});
});
});
diff --git a/addons/mail/views/ir_actions_views.xml b/addons/mail/views/ir_actions_views.xml
index 9a83ebf0316..6d75522b755 100644
--- a/addons/mail/views/ir_actions_views.xml
+++ b/addons/mail/views/ir_actions_views.xml
@@ -7,7 +7,7 @@
<field name="model">ir.actions.server</field>
<field name="inherit_id" ref="base.view_server_action_form"/>
<field name="arch" type="xml">
- <notebook position="inside">
+ <xpath expr="//page[@name='page_object']" position="after">
<page string="Activity" name="next_activity" autofocus="autofocus" attrs="{'invisible': [('state', '!=', 'next_activity')]}">
<group>
<group>
@@ -33,8 +33,8 @@
</group>
<field name="activity_note" placeholder="Log a note..."/>
</page>
- </notebook>
- <field name="link_field_id" position="after">
+ </xpath>
+ <xpath expr="//field[@name='link_field_id']" position="after">
<field name="partner_ids" widget="many2many_tags"
attrs="{'invisible': [('state', '!=', 'followers')]}"/>
<field name="channel_ids" widget="many2many_tags"
@@ -42,7 +42,7 @@
<field name="template_id"
attrs="{'invisible': [('state', '!=', 'email')],
'required': [('state', '=', 'email')]}"/>
- </field>
+ </xpath>
</field>
</record>
diff --git a/addons/mail/views/mail_tracking_views.xml b/addons/mail/views/mail_tracking_views.xml
index cd98078848f..15ddf37b5fa 100644
--- a/addons/mail/views/mail_tracking_views.xml
+++ b/addons/mail/views/mail_tracking_views.xml
@@ -30,27 +30,31 @@
<field name="arch" type="xml">
<form string="Tracking Value">
<sheet>
- <group>
- <group string='Change'>
- <field name="field"/>
- <field name="field_desc"/>
- <field name="field_type"/>
+ <group name="field_details" string="Field details">
+ <field name="field"/>
+ <field name="field_desc"/>
+ <field name="field_type"/>
+ <field name="track_sequence"/>
+ </group>
+ <group name="values">
+ <group name="old_values" string="Old values">
<field name="old_value_integer"/>
<field name="old_value_float"/>
<field name="old_value_char"/>
<field name="old_value_text"/>
<field name="old_value_datetime"/>
+ </group>
+ <group name="new_values" string="New values">
<field name="new_value_integer"/>
<field name="new_value_float"/>
<field name="new_value_char"/>
<field name="new_value_text"/>
<field name="new_value_datetime"/>
- <field name="track_sequence"/>
- </group>
- <group string='Related Message'>
- <field name="mail_message_id"/>
</group>
</group>
+ <group string='Related Message'>
+ <field name="mail_message_id"/>
+ </group>
</sheet>
</form>
</field>
diff --git a/addons/mail/wizard/invite.py b/addons/mail/wizard/invite.py
index 39d6c0bdeeb..35d04f66659 100644
--- a/addons/mail/wizard/invite.py
+++ b/addons/mail/wizard/invite.py
@@ -12,14 +12,14 @@ class Invite(models.TransientModel):
@api.model
def default_get(self, fields):
result = super(Invite, self).default_get(fields)
- user_name = self.env.user.name_get()[0][1]
+ user_name = self.env.user.display_name
model = result.get('res_model')
res_id = result.get('res_id')
if self._context.get('mail_invite_follower_channel_only'):
result['send_mail'] = False
if 'message' in fields and model and res_id:
model_name = self.env['ir.model']._get(model).display_name
- document_name = self.env[model].browse(res_id).name_get()[0][1]
+ document_name = self.env[model].browse(res_id).display_name
message = _('<div><p>Hello,</p><p>%s invited you to follow %s document: %s.</p></div>') % (user_name, model_name, document_name)
result['message'] = message
elif 'message' in fields:
@@ -50,9 +50,9 @@ class Invite(models.TransientModel):
# send an email if option checked and if a message exists (do not send void emails)
if wizard.send_mail and wizard.message and not wizard.message == '<br>': # when deleting the message, cleditor keeps a <br>
message = self.env['mail.message'].create({
- 'subject': _('Invitation to follow %s: %s') % (model_name, document.name_get()[0][1]),
+ 'subject': _('Invitation to follow %s: %s') % (model_name, document.display_name),
'body': wizard.message,
- 'record_name': document.name_get()[0][1],
+ 'record_name': document.display_name,
'email_from': email_from,
'reply_to': email_from,
'model': wizard.res_model,
diff --git a/addons/maintenance/models/maintenance.py b/addons/maintenance/models/maintenance.py
index fe1335c1d31..acb6455e83d 100644
--- a/addons/maintenance/models/maintenance.py
+++ b/addons/maintenance/models/maintenance.py
@@ -98,7 +98,7 @@ class MaintenanceEquipment(models.Model):
def _track_subtype(self, init_values):
self.ensure_one()
if 'owner_user_id' in init_values and self.owner_user_id:
- return 'maintenance.mt_mat_assign'
+ return self.env.ref('maintenance.mt_mat_assign')
return super(MaintenanceEquipment, self)._track_subtype(init_values)
@api.multi
@@ -267,9 +267,9 @@ class MaintenanceRequest(models.Model):
def _track_subtype(self, init_values):
self.ensure_one()
if 'stage_id' in init_values and self.stage_id.sequence <= 1:
- return 'maintenance.mt_req_created'
+ return self.env.ref('maintenance.mt_req_created')
elif 'stage_id' in init_values and self.stage_id.sequence > 1:
- return 'maintenance.mt_req_status'
+ return self.env.ref('maintenance.mt_req_status')
return super(MaintenanceRequest, self)._track_subtype(init_values)
def _get_default_team_id(self):
diff --git a/addons/mass_mailing/__manifest__.py b/addons/mass_mailing/__manifest__.py
index cbf5279f176..b0c6daec3bd 100644
--- a/addons/mass_mailing/__manifest__.py
+++ b/addons/mass_mailing/__manifest__.py
@@ -25,6 +25,7 @@
'wizard/mass_mailing_list_merge_views.xml',
'wizard/test_mailing_views.xml',
'wizard/mass_mailing_schedule_date_views.xml',
+ 'views/mass_mailing_views_menus.xml',
'views/mass_mailing_report_views.xml',
'views/mass_mailing_stats_views.xml',
'views/link_tracker_views.xml',
diff --git a/addons/mass_mailing/controllers/main.py b/addons/mass_mailing/controllers/main.py
index 18b8a814dac..7d43038a3a6 100644
--- a/addons/mass_mailing/controllers/main.py
+++ b/addons/mass_mailing/controllers/main.py
@@ -62,6 +62,10 @@ class MassMailController(http.Controller):
'show_blacklist_button': request.env['ir.config_parameter'].sudo().get_param('mass_mailing.show_blacklist_buttons'),
})
else:
+ opt_in_lists = request.env['mail.mass_mailing.list_contact_rel'].sudo().search([
+ ('contact_id.email', '=', email),
+ ('opt_out', '=', False)
+ ]).mapped('list_id')
blacklist_rec = request.env['mail.blacklist'].sudo()._add(email)
self._log_blacklist_action(
blacklist_rec, mailing_id,
@@ -70,6 +74,7 @@ class MassMailController(http.Controller):
'email': email,
'mailing_id': mailing_id,
'res_id': res_id,
+ 'list_ids': opt_in_lists,
'show_blacklist_button': request.env['ir.config_parameter'].sudo().get_param(
'mass_mailing.show_blacklist_buttons'),
})
diff --git a/addons/mass_mailing/data/mass_mailing_demo.xml b/addons/mass_mailing/data/mass_mailing_demo.xml
index 81a8347912a..1da0c40375b 100644
--- a/addons/mass_mailing/data/mass_mailing_demo.xml
+++ b/addons/mass_mailing/data/mass_mailing_demo.xml
@@ -22,13 +22,34 @@
<record id="mass_mail_contact_2" model="mail.mass_mailing.contact">
<field name="name">Beverly Bridge</field>
<field name="email">bb@example.com</field>
- <field name="list_ids" eval="[(6,0,[ref('mass_mailing.mass_mail_list_2'), ref('mass_mailing.mass_mail_list_2')])]"/>
+ <field name="list_ids" eval="[(6,0,[ref('mass_mailing.mass_mail_list_2')])]"/>
</record>
<record id="mass_mail_contact_3" model="mail.mass_mailing.contact">
<field name="name">Carol Cartridge</field>
<field name="email">cc@example.com</field>
<field name="list_ids" eval="[(6,0,[ref('mass_mailing.mass_mail_list_2')])]"/>
</record>
+ <record id="mass_mail_contact_4" model="mail.mass_mailing.contact">
+ <field name="name">David Dawson</field>
+ <field name="email">dd@example.com</field>
+ </record>
+ <record id="mass_mail_contact_5" model="mail.mass_mailing.contact">
+ <field name="name">Elsa Ericson</field>
+ <field name="email">ee@example.com</field>
+ <field name="list_ids" eval="[(6,0,[ref('mass_mailing.mass_mail_list_2')])]"/>
+ </record>
+
+ <!-- Create Opt-out Records -->
+ <record id="mass_mail_contact_list_rel_1" model="mail.mass_mailing.list_contact_rel">
+ <field name="list_id" eval="ref('mass_mailing.mass_mail_list_2')"/>
+ <field name="contact_id" eval="ref('mass_mailing.mass_mail_contact_4')"/>
+ <field name="opt_out">True</field>
+ </record>
+
+ <!-- Create Blacklist Records -->
+ <record id="blacklist_1" model="mail.blacklist">
+ <field name="email">ee@example.com</field>
+ </record>
<!-- Create campaign and mailings -->
<record id="mass_mail_tag_1" model="mail.mass_mailing.tag">
diff --git a/addons/mass_mailing/views/link_tracker_views.xml b/addons/mass_mailing/views/link_tracker_views.xml
index f7ea013cefd..695768e0f8d 100644
--- a/addons/mass_mailing/views/link_tracker_views.xml
+++ b/addons/mass_mailing/views/link_tracker_views.xml
@@ -1,42 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
- <!-- LINK TRACKER VIEWS -->
- <record model="ir.ui.view" id="view_mail_mass_mailing_links_tree">
- <field name="name">link.tracker.tree</field>
+ <!-- LINK.TRACKER VIEWS -->
+ <record id="link_tracker_view_search" model="ir.ui.view">
+ <field name="name">link.tracker.view.search.inherit.mass.mail</field>
<field name="model">link.tracker</field>
- <field name="priority">10</field>
+ <field name="inherit_id" ref="link_tracker.link_tracker_view_search"/>
<field name="arch" type="xml">
- <tree string="Mailing Statistics of Clicks">
- <field name="title"/>
- <field name="url"/>
- <field name="count"/>
- </tree>
+ <xpath expr="//field[@name='title']" position="after">
+ <field name="mass_mailing_id"/>
+ <field name="mass_mailing_campaign_id"/>
+ </xpath>
</field>
</record>
-
- <record model="ir.ui.view" id="view_mail_mass_mailing_links_search">
- <field name="name">link.tracker.search</field>
+
+ <record id="link_tracker_view_form" model="ir.ui.view">
+ <field name="name">link.tracker.view.form.inherit.mass.mail</field>
<field name="model">link.tracker</field>
- <field name="inherit_id" ref="link_tracker.view_link_tracker_filter"/>
+ <field name="inherit_id" ref="link_tracker.link_tracker_view_form"/>
<field name="arch" type="xml">
- <search>
+ <xpath expr="//field[@name='campaign_id']" position="before">
<field name="mass_mailing_id"/>
<field name="mass_mailing_campaign_id"/>
- </search>
+ </xpath>
</field>
</record>
- <record model="ir.actions.act_window" id="action_view_mass_mailing_links_statistics">
+ <record id="link_tracker_action_mass_mailing" model="ir.actions.act_window">
<field name="name">Statistics of Clicks</field>
<field name="res_model">link.tracker</field>
- <field name="view_mode">tree</field>
+ <field name="view_type">form</field>
+ <field name="view_mode">tree,form,graph</field>
+ <field name="view_id" ref="link_tracker.link_tracker_view_tree"/>
+ <field name="help" type="html">
+ <p class="o_view_nocontent_smiling_face">
+ Create a new link tracker
+ </p>
+ </field>
<field name="context">{'search_default_mass_mailing_id': active_id}</field>
</record>
-
- <record model="ir.actions.act_window" id="dropdb snipp">
+
+ <record id="link_tracker_action_mass_mailing_campaign" model="ir.actions.act_window">
<field name="name">Statistics of Clicks</field>
<field name="res_model">link.tracker</field>
- <field name="view_mode">tree</field>
- <field name="context">{'search_default_mass_mailing_campaign_id': active_id}</field>
+ <field name="view_type">form</field>
+ <field name="view_mode">tree,form,graph</field>
+ <field name="view_id" ref="link_tracker.link_tracker_view_tree"/>
+ <field name="help" type="html">
+ <p class="o_view_nocontent_smiling_face">
+ Create a new link tracker
+ </p>
+ </field>
+ <field name="context">{'id="link_tracker_action_mass_mailing"': active_id}</field>
</record>
+
+ <!-- MENU TO HANLDE LINK DATA IN MM -->
+ <menuitem id="link_tracker_menu_mass_mailing"
+ name="Link Tracker"
+ parent="mass_mailing_configuration"
+ sequence="5"
+ action="link_tracker.link_tracker_action"/>
+
</odoo>
diff --git a/addons/mass_mailing/views/mass_mailing_blacklist_views.xml b/addons/mass_mailing/views/mass_mailing_blacklist_views.xml
index 9940d09ba8c..85118d3f66d 100644
--- a/addons/mass_mailing/views/mass_mailing_blacklist_views.xml
+++ b/addons/mass_mailing/views/mass_mailing_blacklist_views.xml
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
- <menuitem id="mail_blacklist_mm_menu" name="Blacklist" action="mail.mail_blacklist_action" parent="menu_mass_mailing_configuration"/>
+ <menuitem id="mail_blacklist_mm_menu" name="Blacklist" action="mail.mail_blacklist_action" parent="mass_mailing_configuration"/>
</odoo>
diff --git a/addons/mass_mailing/views/mass_mailing_views.xml b/addons/mass_mailing/views/mass_mailing_views.xml
index ab86e32d87d..97956b7ee85 100644
--- a/addons/mass_mailing/views/mass_mailing_views.xml
+++ b/addons/mass_mailing/views/mass_mailing_views.xml
@@ -1,14 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
-
- <!-- Marketing / Mailing -->
- <menuitem name="Email Marketing" id="mass_mailing_menu_root" sequence="60" web_icon="mass_mailing,static/description/icon.png"/>
- <menuitem name="Mailing Lists" id="mass_mailing_mailing_list_menu"
- parent="mass_mailing_menu_root" sequence="2" groups="mass_mailing.group_mass_mailing_user"/>
- <!-- Marketing / Configuration -->
- <menuitem name="Configuration" id="mass_mailing_configuration"
- parent="mass_mailing_menu_root" sequence="100"/>
-
<!-- MAILING CONTACT -->
<record model="ir.ui.view" id="mail_mass_mailing_list_contact_rel_view_form">
<!--TODO DBE : To remove - Deprecated -->
@@ -722,7 +713,7 @@
</div>
<div class="row">
<div class="col-8">
- <a name="%(action_view_mass_mailing_links_statistics)d"
+ <a name="%(link_tracker_action_mass_mailing)d"
type="action">Clicks</a>
</div>
<div class="col-4 text-right">
@@ -876,11 +867,8 @@
</field>
</record>
- <menuitem id="menu_mass_mailing_configuration" name="Configuration" parent="mass_mailing_menu_root"
- sequence="100" groups="mass_mailing.group_mass_mailing_user"/>
-
<menuitem name="Campaign Stages" id="menu_view_mass_mailing_stages"
- parent="menu_mass_mailing_configuration" sequence="1"
+ parent="mass_mailing_configuration" sequence="1"
groups="mass_mailing.group_mass_mailing_campaign"
action="action_view_mass_mailing_stages"/>
@@ -907,7 +895,7 @@
</field>
</record>
- <menuitem id="mass_mailing_tag_menu" parent="menu_mass_mailing_configuration" action="mass_mailing_tag_action" sequence="2" groups="base.group_no_one"/>
+ <menuitem id="mass_mailing_tag_menu" parent="mass_mailing_configuration" action="mass_mailing_tag_action" sequence="2" groups="base.group_no_one"/>
<!-- MAILING CAMPAIGNS !-->
@@ -978,7 +966,7 @@
type="action" class="oe_stat_button">
<field name="replied_ratio" widget="percentpie" string="Replied"/>
</button>
- <button name="%(dropdb snipp)d"
+ <button name="%(link_tracker_action_mass_mailing_campaign)d"
type="action" class="oe_stat_button">
<field name="clicks_ratio" widget="percentpie" string="Clicks"/>
</button>
diff --git a/addons/mass_mailing/views/mass_mailing_views_menus.xml b/addons/mass_mailing/views/mass_mailing_views_menus.xml
new file mode 100644
index 00000000000..57832e6850a
--- /dev/null
+++ b/addons/mass_mailing/views/mass_mailing_views_menus.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <!-- Marketing / Mailing -->
+ <menuitem name="Email Marketing" id="mass_mailing_menu_root" sequence="60" web_icon="mass_mailing,static/description/icon.png"/>
+ <menuitem name="Mailing Lists" id="mass_mailing_mailing_list_menu"
+ parent="mass_mailing_menu_root" sequence="2" groups="mass_mailing.group_mass_mailing_user"/>
+ <!-- Marketing / Configuration -->
+ <menuitem name="Configuration" id="mass_mailing_configuration"
+ parent="mass_mailing_menu_root"
+ sequence="100"
+ groups="mass_mailing.group_mass_mailing_user"/>
+</odoo> \ No newline at end of file
diff --git a/addons/mass_mailing/views/res_config_settings_views.xml b/addons/mass_mailing/views/res_config_settings_views.xml
index 38600acbec9..a8a56b27461 100644
--- a/addons/mass_mailing/views/res_config_settings_views.xml
+++ b/addons/mass_mailing/views/res_config_settings_views.xml
@@ -74,8 +74,6 @@
<field name="context">{'module' : 'mass_mailing'}</field>
</record>
- <menuitem id="menu_mass_mailing_configuration" name="Configuration" parent="mass_mailing_menu_root"
- sequence="100"/>
<menuitem id="menu_mass_mailing_global_settings" name="Settings"
- parent="menu_mass_mailing_configuration" sequence="0" action="action_mass_mailing_configuration" groups="base.group_system"/>
+ parent="mass_mailing_configuration" sequence="0" action="action_mass_mailing_configuration" groups="base.group_system"/>
</odoo>
diff --git a/addons/mass_mailing/views/unsubscribe_templates.xml b/addons/mass_mailing/views/unsubscribe_templates.xml
index 1822ac08345..9417aeef044 100644
--- a/addons/mass_mailing/views/unsubscribe_templates.xml
+++ b/addons/mass_mailing/views/unsubscribe_templates.xml
@@ -71,6 +71,19 @@
<p>You have been successfully <strong>unsubscribed</strong>!</p>
</div>
+ <div t-if="list_ids" class="alert alert-warning">
+ <p class="text-center">You are currently subscribed to those newsletters. Unsubscribing means you will not receive any news from them anymore:</p>
+ <ul class="list-group mb-4">
+ <t t-foreach="list_ids" t-as="list_id">
+ <t t-if="list_id.is_public == True">
+ <li class="list-group-item bg-transparent">
+ <strong><t t-esc="list_id.name"/></strong>
+ </li>
+ </t>
+ </t>
+ </ul>
+ </div>
+
<div t-if="show_blacklist_button" class="mb64">
<div class="btn btn-secondary pull-right" id="button_add_blacklist" style="display:none">Blacklist Me</div>
<div class="btn btn-secondary pull-right" id="button_remove_blacklist" style="display:none">Come Back</div>
diff --git a/addons/mrp/models/mrp_workorder.py b/addons/mrp/models/mrp_workorder.py
index d7658edb70e..65618c2de82 100644
--- a/addons/mrp/models/mrp_workorder.py
+++ b/addons/mrp/models/mrp_workorder.py
@@ -422,20 +422,19 @@ class MrpWorkorder(models.Model):
loss_id = self.env['mrp.workcenter.productivity.loss'].search([('loss_type','=','performance')], limit=1)
if not len(loss_id):
raise UserError(_("You need to define at least one productivity loss in the category 'Performance'. Create one from the Manufacturing app, menu: Configuration / Productivity Losses."))
- for workorder in self:
- if workorder.production_id.state != 'progress':
- workorder.production_id.write({
- 'state': 'progress',
- 'date_start': datetime.now(),
- })
- timeline.create({
- 'workorder_id': workorder.id,
- 'workcenter_id': workorder.workcenter_id.id,
- 'description': _('Time Tracking: ')+self.env.user.name,
- 'loss_id': loss_id[0].id,
+ if self.production_id.state != 'progress':
+ self.production_id.write({
+ 'state': 'progress',
'date_start': datetime.now(),
- 'user_id': self.env.user.id
})
+ timeline.create({
+ 'workorder_id': self.id,
+ 'workcenter_id': self.workcenter_id.id,
+ 'description': _('Time Tracking: ')+self.env.user.name,
+ 'loss_id': loss_id[0].id,
+ 'date_start': datetime.now(),
+ 'user_id': self.env.user.id
+ })
return self.write({'state': 'progress',
'date_start': datetime.now(),
})
diff --git a/addons/mrp/models/stock_warehouse.py b/addons/mrp/models/stock_warehouse.py
index ab0bfeff5c2..c66d15131c6 100644
--- a/addons/mrp/models/stock_warehouse.py
+++ b/addons/mrp/models/stock_warehouse.py
@@ -59,9 +59,7 @@ class StockWarehouse(models.Model):
@api.model
def _get_production_location(self):
- location = self.env.ref('stock.location_production', raise_if_not_found=False)
- if not location:
- location = self.env['stock.location'].search([('usage', '=', 'production')], limit=1)
+ location = self.env['stock.location'].with_context(force_company=self.company_id.id).search([('usage', '=', 'production'), ('company_id', '=', self.company_id.id)], limit=1)
if not location:
raise UserError(_('Can\'t find any production location.'))
return location
@@ -243,17 +241,8 @@ class StockWarehouse(models.Model):
return routes
def _update_location_manufacture(self, new_manufacture_step):
- switch_warehouses = self.filtered(lambda wh: wh.manufacture_steps != new_manufacture_step)
- loc_warehouse = switch_warehouses.filtered(lambda wh: not wh._location_used(wh.pbm_loc_id))
- if loc_warehouse:
- loc_warehouse.mapped('pbm_loc_id').write({'active': False})
- loc_warehouse = switch_warehouses.filtered(lambda wh: not wh._location_used(wh.sam_loc_id))
- if loc_warehouse:
- loc_warehouse.mapped('sam_loc_id').write({'active': False})
- if new_manufacture_step != 'mrp_one_step':
- self.mapped('pbm_loc_id').write({'active': True})
- if new_manufacture_step == 'pbm_sam':
- self.mapped('sam_loc_id').write({'active': True})
+ self.mapped('pbm_loc_id').write({'active': new_manufacture_step != 'mrp_one_step'})
+ self.mapped('sam_loc_id').write({'active': new_manufacture_step == 'pbm_sam'})
@api.multi
def _update_name_and_code(self, name=False, code=False):
diff --git a/addons/mrp/report/report_stock_rule.py b/addons/mrp/report/report_stock_rule.py
index d25960d1183..2f25ca6a6bd 100644
--- a/addons/mrp/report/report_stock_rule.py
+++ b/addons/mrp/report/report_stock_rule.py
@@ -13,5 +13,5 @@ class ReportStockRule(models.AbstractModel):
"""
res = super(ReportStockRule, self)._get_rule_loc(rule, product_id)
if rule.action == 'manufacture':
- res['source'] = product_id.property_stock_production or self.env.ref('stock.location_production')
+ res['source'] = product_id.property_stock_production
return res
diff --git a/addons/mrp/views/mrp_workcenter_views.xml b/addons/mrp/views/mrp_workcenter_views.xml
index 78c0f756678..8172349a27b 100644
--- a/addons/mrp/views/mrp_workcenter_views.xml
+++ b/addons/mrp/views/mrp_workcenter_views.xml
@@ -448,12 +448,12 @@
<field name="model">mrp.workcenter.productivity.loss</field>
<field name="arch" type="xml">
<form string="Workcenter Productivity Loss">
- <group>
+ <sheet>
<group>
<field name="name"/>
<field name="loss_id" widget="selection"/>
</group>
- </group>
+ </sheet>
</form>
</field>
</record>
diff --git a/addons/note/data/res_users_data.xml b/addons/note/data/res_users_data.xml
index 874982a310b..787bc731f87 100644
--- a/addons/note/data/res_users_data.xml
+++ b/addons/note/data/res_users_data.xml
@@ -1,7 +1,7 @@
<?xml version="1.0"?>
<odoo>
<data noupdate="1">
- <function id="init_data_user_note_stages"
+ <function
model="res.users"
name="_init_data_user_note_stages"
eval="[]"/>
diff --git a/addons/point_of_sale/models/pos_config.py b/addons/point_of_sale/models/pos_config.py
index 8411e864faa..9e130cb5dca 100644
--- a/addons/point_of_sale/models/pos_config.py
+++ b/addons/point_of_sale/models/pos_config.py
@@ -137,7 +137,8 @@ class PosConfig(models.Model):
help="Make several pricelists available in the Point of Sale. You can also apply a pricelist to specific customers from their contact form (in Sales tab). To be valid, this pricelist must be listed here as an available pricelist. Otherwise the default pricelist will apply.")
company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.user.company_id)
barcode_nomenclature_id = fields.Many2one('barcode.nomenclature', string='Barcode Nomenclature',
- help='Defines what kind of barcodes are available and how they are assigned to products, customers and cashiers.')
+ help='Defines what kind of barcodes are available and how they are assigned to products, customers and cashiers.',
+ default=lambda self: self.env.user.company_id.nomenclature_id)
group_pos_manager_id = fields.Many2one('res.groups', string='Point of Sale Manager Group', default=_get_group_pos_manager,
help='This field is there to pass the id of the pos manager group to the point of sale client.')
group_pos_user_id = fields.Many2one('res.groups', string='Point of Sale User Group', default=_get_group_pos_user,
@@ -152,7 +153,6 @@ class PosConfig(models.Model):
use_pricelist = fields.Boolean("Use a pricelist.")
tax_regime = fields.Boolean("Tax Regime")
tax_regime_selection = fields.Boolean("Tax Regime Selection value")
- barcode_scanner = fields.Boolean("Barcode Scanner")
start_category = fields.Boolean("Set Start Category")
module_account = fields.Boolean(string='Invoicing', help='Enables invoice generation from the Point of Sale.')
module_pos_restaurant = fields.Boolean("Is a Bar/Restaurant")
@@ -282,20 +282,6 @@ class PosConfig(models.Model):
if self.pricelist_id not in self.available_pricelist_ids:
self.pricelist_id = False
- @api.onchange('iface_scan_via_proxy')
- def _onchange_iface_scan_via_proxy(self):
- if self.iface_scan_via_proxy:
- self.barcode_scanner = True
- else:
- self.barcode_scanner = False
-
- @api.onchange('barcode_scanner')
- def _onchange_barcode_scanner(self):
- if self.barcode_scanner:
- self.barcode_nomenclature_id = self.env.user.company_id.nomenclature_id
- else:
- self.barcode_nomenclature_id = False
-
@api.onchange('is_posbox')
def _onchange_is_posbox(self):
if not self.is_posbox:
@@ -438,6 +424,11 @@ class PosConfig(models.Model):
"""
self.ensure_one()
if not self.current_session_id:
+ self._check_company_location()
+ self._check_company_journal()
+ self._check_company_invoice_journal()
+ self._check_company_payment()
+ self._check_currencies()
self.current_session_id = self.env['pos.session'].create({
'user_id': self.env.uid,
'config_id': self.id
diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py
index ff98c6c944d..81cc126b004 100644
--- a/addons/point_of_sale/models/pos_order.py
+++ b/addons/point_of_sale/models/pos_order.py
@@ -218,7 +218,7 @@ class PosOrder(models.Model):
def _action_create_invoice_line(self, line=False, invoice_id=False):
InvoiceLine = self.env['account.invoice.line']
- inv_name = line.product_id.name_get()[0][1]
+ inv_name = line.product_id.display_name
inv_line = {
'invoice_id': invoice_id,
'product_id': line.product_id.id,
diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js
index 396ad060b36..68e819f30b7 100644
--- a/addons/point_of_sale/static/src/js/models.js
+++ b/addons/point_of_sale/static/src/js/models.js
@@ -302,7 +302,7 @@ exports.PosModel = Backbone.Model.extend({
loaded: function(self, locations){ self.shop = locations[0]; },
},{
model: 'product.pricelist',
- fields: ['name', 'display_name'],
+ fields: ['name', 'display_name', 'discount_policy'],
domain: function(self) { return [['id', 'in', self.config.available_pricelist_ids]]; },
loaded: function(self, pricelists){
_.map(pricelists, function (pricelist) { pricelist.items = []; });
@@ -361,8 +361,7 @@ exports.PosModel = Backbone.Model.extend({
},
},{
model: 'product.product',
- // todo remove list_price in master, it is unused
- fields: ['display_name', 'list_price', 'lst_price', 'standard_price', 'categ_id', 'pos_categ_id', 'taxes_id',
+ fields: ['display_name', 'lst_price', 'standard_price', 'categ_id', 'pos_categ_id', 'taxes_id',
'barcode', 'default_code', 'to_weight', 'uom_id', 'description_sale', 'description',
'product_tmpl_id','tracking'],
order: _.map(['sequence','default_code','name'], function (name) { return {name: name}; }),
@@ -1566,6 +1565,9 @@ exports.Orderline = Backbone.Model.extend({
discount: this.get_discount(),
product_name: this.get_product().display_name,
product_name_wrapped: this.generate_wrapped_product_name(),
+ price_lst: this.get_lst_price(),
+ display_discount_policy: this.display_discount_policy(),
+ price_display_one: this.get_display_price_one(),
price_display : this.get_display_price(),
price_with_tax : this.get_price_with_tax(),
price_without_tax: this.get_price_without_tax(),
@@ -1630,6 +1632,28 @@ exports.Orderline = Backbone.Model.extend({
var rounding = this.pos.currency.rounding;
return round_pr(this.get_unit_price() * this.get_quantity() * (1 - this.get_discount()/100), rounding);
},
+ get_display_price_one: function(){
+ var rounding = this.pos.currency.rounding;
+ var price_unit = this.get_unit_price() * (1.0 - (this.get_discount() / 100.0));
+ if (this.pos.config.iface_tax_included !== 'total') {
+ return round_pr(price_unit, rounding);
+ } else {
+ var product = this.get_product();
+ var taxes_ids = product.taxes_id;
+ var taxes = this.pos.taxes;
+ var product_taxes = [];
+
+ _(taxes_ids).each(function(el){
+ product_taxes.push(_.detect(taxes, function(t){
+ return t.id === el;
+ }));
+ });
+
+ var all_taxes = this.compute_all(product_taxes, price_unit, 1, this.pos.currency.rounding);
+
+ return round_pr(all_taxes.total_included * (1 - this.get_discount()/100), rounding);
+ }
+ },
get_display_price: function(){
if (this.pos.config.iface_tax_included === 'total') {
return this.get_price_with_tax();
@@ -1675,20 +1699,26 @@ exports.Orderline = Backbone.Model.extend({
return taxes;
},
_map_tax_fiscal_position: function(tax) {
+ var self = this;
var current_order = this.pos.get_order();
var order_fiscal_position = current_order && current_order.fiscal_position;
+ var taxes = [];
if (order_fiscal_position) {
- var mapped_tax = _.find(order_fiscal_position.fiscal_position_taxes_by_id, function (fiscal_position_tax) {
+ var tax_mappings = _.filter(order_fiscal_position.fiscal_position_taxes_by_id, function (fiscal_position_tax) {
return fiscal_position_tax.tax_src_id[0] === tax.id;
});
- if (mapped_tax) {
- tax = this.pos.taxes_by_id[mapped_tax.tax_dest_id[0]];
+ if (tax_mappings) {
+ _.each(tax_mappings, function(tm) {
+ taxes.push(self.pos.taxes_by_id[tm.tax_dest_id[0]]);
+ });
}
+ } else {
+ taxes.push(tax);
}
- return tax;
+ return taxes;
},
_compute_all: function(tax, base_amount, quantity) {
if (tax.amount_type === 'fixed') {
@@ -1716,13 +1746,18 @@ exports.Orderline = Backbone.Model.extend({
var total_excluded = round_pr(price_unit * quantity, currency_rounding);
var total_included = total_excluded;
var base = total_excluded;
- _(taxes).each(function(tax) {
- if (!no_map_tax){
- tax = self._map_tax_fiscal_position(tax);
- }
- if (!tax){
- return;
- }
+ var taxes_mapped = [];
+
+ if (!no_map_tax){
+ _(taxes).each(function(tax){
+ _(self._map_tax_fiscal_position(tax)).each(function(tax){
+ taxes_mapped.push(tax);
+ });
+ });
+ } else {
+ taxes_mapped = taxes;
+ }
+ _(taxes_mapped).each(function(tax) {
if (tax.amount_type === 'group'){
var ret = self.compute_all(tax.children_tax_ids, price_unit, quantity, currency_rounding);
total_excluded = ret.total_excluded;
@@ -1789,6 +1824,12 @@ exports.Orderline = Backbone.Model.extend({
"taxDetails": taxdetail,
};
},
+ display_discount_policy: function(){
+ return this.order.pricelist.discount_policy;
+ },
+ get_lst_price: function(){
+ return this.product.lst_price;
+ },
});
var OrderlineCollection = Backbone.Collection.extend({
@@ -2298,12 +2339,12 @@ exports.Order = Backbone.Model.extend({
var taxes = line.get_taxes();
var mapped_included_taxes = [];
_(taxes).each(function(tax) {
- var line_tax = line._map_tax_fiscal_position(tax);
- if(tax.price_include && tax.id != line_tax.id){
+ var line_taxes = line._map_tax_fiscal_position(tax);
+ if(tax.price_include && _.contains(line_taxes, tax)){
mapped_included_taxes.push(tax);
}
- })
+ });
if (mapped_included_taxes.length > 0) {
unit_price = line.compute_all(mapped_included_taxes, unit_price, 1, this.pos.currency.rounding, true).total_excluded;
@@ -2355,10 +2396,11 @@ exports.Order = Backbone.Model.extend({
}
if (to_merge_orderline){
to_merge_orderline.merge(line);
+ this.select_orderline(to_merge_orderline);
} else {
this.orderlines.add(line);
+ this.select_orderline(this.get_last_orderline());
}
- this.select_orderline(this.get_last_orderline());
if(line.has_product_lot){
this.display_lot_popup();
@@ -2461,7 +2503,9 @@ exports.Order = Backbone.Model.extend({
},
get_total_discount: function() {
return round_pr(this.orderlines.reduce((function(sum, orderLine) {
- return sum + (orderLine.get_unit_price() * (orderLine.get_discount()/100) * orderLine.get_quantity());
+ sum += (orderLine.get_unit_price() * (orderLine.get_discount()/100) * orderLine.get_quantity());
+ sum += ((orderLine.get_lst_price() - orderLine.get_unit_price()) * orderLine.get_quantity());
+ return sum;
}), 0), this.pos.currency.rounding);
},
get_total_tax: function() {
diff --git a/addons/point_of_sale/static/src/js/screens.js b/addons/point_of_sale/static/src/js/screens.js
index 15fca79fc3d..de895eee9b0 100644
--- a/addons/point_of_sale/static/src/js/screens.js
+++ b/addons/point_of_sale/static/src/js/screens.js
@@ -2246,7 +2246,7 @@ define_action_button({
'name': 'set_pricelist',
'widget': set_pricelist_button,
'condition': function(){
- return this.pos.pricelists.length > 1;
+ return this.pos.config.use_pricelist && this.pos.pricelists.length > 1;
},
});
diff --git a/addons/point_of_sale/static/src/js/tests.js b/addons/point_of_sale/static/src/js/tests.js
index 2048d3d8271..59738e08179 100644
--- a/addons/point_of_sale/static/src/js/tests.js
+++ b/addons/point_of_sale/static/src/js/tests.js
@@ -230,6 +230,20 @@ odoo.define('point_of_sale.tour.acceptance', function (require) {
}];
}
+ function set_fiscal_position_on_order(fp_name) {
+ return [{
+ content: 'set fiscal position',
+ trigger: '.control-button.o_fiscal_position_button',
+ }, {
+ content: 'choose fiscal position ' + fp_name + ' to add to the order',
+ trigger: '.popups .popup .selection .selection-item:contains("' + fp_name + '")',
+ }, {
+ content: 'the fiscal position ' + fp_name + ' has been set to the order',
+ trigger: '.control-button.o_fiscal_position_button:contains("' + fp_name + '")',
+ run: function () {}, // it's a check
+ }];
+ }
+
function generate_keypad_steps(amount_str, keypad_selector) {
var i, steps = [], current_char;
for (i = 0; i < amount_str.length; ++i) {
@@ -321,6 +335,12 @@ odoo.define('point_of_sale.tour.acceptance', function (require) {
steps = steps.concat(generate_payment_screen_keypad_steps("10"));
steps = steps.concat(finish_order());
+ // Test fiscal position one2many map (align with backend)
+ steps = steps.concat(add_product_to_order('Letter Tray'));
+ steps = steps.concat(verify_order_total('5.28'));
+ steps = steps.concat(set_fiscal_position_on_order('FP-POS-2M'));
+ steps = steps.concat(verify_order_total('5.52'));
+
steps = steps.concat([{
content: "close the Point of Sale frontend",
trigger: ".header-button",
diff --git a/addons/point_of_sale/static/src/xml/pos.xml b/addons/point_of_sale/static/src/xml/pos.xml
index fe3d86efc21..95ecfc9874f 100644
--- a/addons/point_of_sale/static/src/xml/pos.xml
+++ b/addons/point_of_sale/static/src/xml/pos.xml
@@ -124,7 +124,7 @@
</t>
<t t-name="SetFiscalPositionButton">
- <div class='control-button'>
+ <div class='control-button o_fiscal_position_button'>
<i class='fa fa-book' role="img" aria-label="Set fiscal position" title="Set fiscal position"/> <t t-esc='widget.get_current_fiscal_position_name()'/>
</div>
</t>
@@ -775,19 +775,44 @@
<div class='orderlines' line-ratio='0.6'>
<t t-foreach='receipt.orderlines' t-as='line'>
- <t t-set='simple' t-value='line.discount === 0 and line.unit_name === "Unit(s)" and line.quantity === 1' />
+ <t t-set='simple' t-value='(line.discount === 0 and line.unit_name === "Unit(s)" and line.quantity === 1 and !discount_to_show and !(line.display_discount_policy == "without_discount" &amp;&amp; line.price != line.price_lst))' />
<t t-if='simple'>
<line>
<left><t t-esc='line.product_name_wrapped[0]' /></left>
<right><value t-att-value-decimals='pos.currency.decimals'><t t-esc='line.price_display' /></value></right>
</line>
- <t t-call="XmlReceiptWrappedProductNameLines"/>
</t>
+ <t t-call="XmlReceiptWrappedProductNameLines"/>
<t t-if='!simple'>
<line><left><t t-esc='line.product_name_wrapped[0]' /></left></line>
<t t-call="XmlReceiptWrappedProductNameLines"/>
+ <t t-if="line.display_discount_policy == 'without_discount' &amp;&amp; line.price != line.price_lst">
+ <line indent='1'>
+ <left>
+ <value t-att-value-decimals='pos.dp["Product Price"]'>
+ <t t-esc="line.price_lst" />
+ </value> ->
+ <value t-att-value-decimals='pos.dp["Product Price"]'>
+ <t t-esc="line.price" />
+ </value>
+ </left>
+ </line>
+ </t>
+ <t t-else='line.discount !== 0'>
+ <line indent='1'>
+ <left>
+ <value t-att-value-decimals='pos.dp["Product Price"]'>
+ <t t-esc="line.price_lst" />
+ </value>
+ </left>
+ </line>
+ </t>
<t t-if='line.discount !== 0'>
- <line indent='1'><left>Discount: <t t-esc='line.discount' />%</left></line>
+ <line indent='1'>
+ <left>
+ Discount: <t t-esc='line.discount' />%
+ </left>
+ </line>
</t>
<line indent='1'>
<left>
@@ -799,7 +824,7 @@
</t>
x
<value t-att-value-decimals='pos.dp["Product Price"]'>
- <t t-esc='line.price' />
+ <t t-esc='line.price_display_one' />
</value>
</left>
<right>
@@ -1284,7 +1309,16 @@
</em>
<t t-esc="line.get_unit().name" />
at
- <t t-esc="widget.format_currency(line.get_unit_display_price(),'Product Price')" />
+ <t t-if="line.display_discount_policy() == 'without_discount' &amp;&amp;
+ line.get_unit_display_price() != line.get_lst_price()">
+ <s>
+ <t t-esc="widget.format_currency(line.get_lst_price(),'Product Price')" />
+ </s>
+ <t t-esc="widget.format_currency(line.get_unit_display_price(),'Product Price')" />
+ </t>
+ <t t-else="">
+ <t t-esc="widget.format_currency(line.get_unit_display_price(),'Product Price')" />
+ </t>
/
<t t-esc="line.get_unit().name" />
</li>
@@ -1472,6 +1506,15 @@
<tr t-foreach="orderlines" t-as="orderline">
<td>
<t t-esc="orderline.get_product().display_name"/>
+ <t t-if="orderline.display_discount_policy() == 'without_discount' &amp;&amp;
+ orderline.get_unit_display_price() != orderline.get_lst_price()">
+ <div class="pos-disc-font">
+ <s>
+ <t t-esc="widget.format_currency(orderline.get_lst_price(),'Product Price')"/>
+ </s> ->
+ <t t-esc="widget.format_currency(orderline.get_unit_display_price(),'Product Price')"/>
+ </div>
+ </t>
<t t-if="orderline.get_discount() > 0">
<div class="pos-disc-font">
With a <t t-esc="orderline.get_discount()"/>% discount
diff --git a/addons/point_of_sale/tests/test_frontend.py b/addons/point_of_sale/tests/test_frontend.py
index cea15d9e5a6..6e178919ed3 100644
--- a/addons/point_of_sale/tests/test_frontend.py
+++ b/addons/point_of_sale/tests/test_frontend.py
@@ -265,7 +265,22 @@ class TestUi(odoo.tests.HttpCase):
all_pricelists = env['product.pricelist'].search([('id', '!=', excluded_pricelist.id)])
all_pricelists.write(dict(currency_id=main_company.currency_id.id))
+ src_tax = env['account.tax'].create({'name': "SRC", 'amount': 10})
+ dst_tax = env['account.tax'].create({'name': "DST", 'amount': 5})
+
+ env.ref('point_of_sale.letter_tray').taxes_id = [(6, 0, [src_tax.id])]
+
+
main_pos_config.write({
+ 'tax_regime_selection': True,
+ 'fiscal_position_ids': [(0, 0, {
+ 'name': "FP-POS-2M",
+ 'tax_ids': [
+ (0,0,{'tax_src_id': src_tax.id,
+ 'tax_dest_id': src_tax.id}),
+ (0,0,{'tax_src_id': src_tax.id,
+ 'tax_dest_id': dst_tax.id})]
+ })],
'journal_id': test_sale_journal.id,
'invoice_journal_id': test_sale_journal.id,
'journal_ids': [(0, 0, {'name': 'Cash Journal - Test',
@@ -273,6 +288,7 @@ class TestUi(odoo.tests.HttpCase):
'type': 'cash',
'company_id': main_company.id,
'journal_user': True})],
+ 'use_pricelist': True,
'available_pricelist_ids': [(4, pricelist.id) for pricelist in all_pricelists],
})
diff --git a/addons/point_of_sale/views/pos_config_view.xml b/addons/point_of_sale/views/pos_config_view.xml
index 3ba93f7156b..0f544bdde2a 100644
--- a/addons/point_of_sale/views/pos_config_view.xml
+++ b/addons/point_of_sale/views/pos_config_view.xml
@@ -157,16 +157,15 @@
</div>
<div id="barcode_scanner" class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
- <field name="barcode_scanner"/>
</div>
<div class="o_setting_right_pane">
- <label for="barcode_scanner" string="Barcode Scanner"/>
+ <span class="o_form_label">Barcode Scanner</span>
<div class="text-muted">
Set barcodes to scan products, customer cards, etc.
</div>
- <div class="content-group row mt16" attrs="{'invisible': [('barcode_scanner', '=', False)]}">
+ <div class="content-group mt16 row">
<label for="barcode_nomenclature_id" string="Barcode Nomenclature" class="col-lg-3 o_light_label"/>
- <field name="barcode_nomenclature_id" attrs="{'required': [('barcode_scanner', '=', True)]}"/>
+ <field name="barcode_nomenclature_id"/>
</div>
</div>
</div>
diff --git a/addons/product/models/product.py b/addons/product/models/product.py
index dadb60c4ce6..b1b2340f16d 100644
--- a/addons/product/models/product.py
+++ b/addons/product/models/product.py
@@ -235,7 +235,7 @@ class ProductProduct(models.Model):
self.partner_ref = '%s%s' % (self.code and '[%s] ' % self.code or '', product_name)
break
else:
- self.partner_ref = self.name_get()[0][1]
+ self.partner_ref = self.display_name
@api.one
@api.depends('image_variant', 'product_tmpl_id.image')
@@ -381,6 +381,7 @@ class ProductProduct(models.Model):
partner_ids = [partner_id, self.env['res.partner'].browse(partner_id).commercial_partner_id.id]
else:
partner_ids = []
+ company_id = self.env.context.get('company_id')
# all user don't have access to seller and partner
# check access and use superuser
@@ -399,6 +400,11 @@ class ProductProduct(models.Model):
sellers = [x for x in product.seller_ids if (x.name.id in partner_ids) and (x.product_id == product)]
if not sellers:
sellers = [x for x in product.seller_ids if (x.name.id in partner_ids) and not x.product_id]
+ # Filter out sellers based on the company. This is done afterwards for a better
+ # code readability. At this point, only a few sellers should remain, so it should
+ # not be a performance issue.
+ if company_id:
+ sellers = [x for x in sellers if x.company_id.id in [company_id, False]]
if sellers:
for s in sellers:
seller_variant = s.product_name and (
diff --git a/addons/product/models/product_pricelist.py b/addons/product/models/product_pricelist.py
index 44461a789d1..bec6eb8fdcf 100644
--- a/addons/product/models/product_pricelist.py
+++ b/addons/product/models/product_pricelist.py
@@ -37,6 +37,11 @@ class Pricelist(models.Model):
country_group_ids = fields.Many2many('res.country.group', 'res_country_group_pricelist_rel',
'pricelist_id', 'res_country_group_id', string='Country Groups')
+ discount_policy = fields.Selection([
+ ('with_discount', 'Discount included in the price'),
+ ('without_discount', 'Show public price & discount to the customer')],
+ default='with_discount')
+
@api.multi
def name_get(self):
return [(pricelist.id, '%s (%s)' % (pricelist.name, pricelist.currency_id.name)) for pricelist in self]
diff --git a/addons/product/tests/test_seller.py b/addons/product/tests/test_seller.py
index 36693317c34..830b277da1d 100644
--- a/addons/product/tests/test_seller.py
+++ b/addons/product/tests/test_seller.py
@@ -10,6 +10,11 @@ class TestSeller(TransactionCase):
super(TestSeller, self).setUp()
self.product_service = self.env.ref('product.product_product_2')
self.product_service.default_code = 'DEFCODE'
+ self.product_consu = self.env['product.product'].create({
+ 'name': 'Boudin',
+ 'type': 'consu',
+ })
+ self.product_consu.default_code = 'DEFCODE'
self.asustec = self.env.ref('base.res_partner_1')
self.camptocamp = self.env.ref('base.res_partner_12')
@@ -26,3 +31,35 @@ class TestSeller(TransactionCase):
.with_context(partner_id=self.camptocamp.id)\
.code
self.assertEqual('C2CCODE', context_code, "Partner's code not used in product name with context set")
+
+ def test_20_sellers_company(self):
+ company_a = self.env.user.company_id
+ company_b = self.env['res.company'].create({
+ 'name': 'Saucisson Inc.',
+ })
+ self.product_consu.write({'seller_ids': [
+ (0, 0, {'name': self.asustec.id, 'product_code': 'A', 'company_id': company_a.id}),
+ (0, 0, {'name': self.asustec.id, 'product_code': 'B', 'company_id': company_b.id}),
+ (0, 0, {'name': self.asustec.id, 'product_code': 'NO', 'company_id': False}),
+ ]})
+
+ names = self.product_consu.with_context(
+ partner_id=self.asustec.id,
+ ).name_get()
+ ref = set([x[1] for x in names])
+ self.assertEqual(len(names), 3, "3 vendor references should have been found")
+ self.assertEqual(ref, {'[A] Boudin', '[B] Boudin', '[NO] Boudin'}, "Incorrect vendor reference list")
+ names = self.product_consu.with_context(
+ partner_id=self.asustec.id,
+ company_id=company_a.id,
+ ).name_get()
+ ref = set([x[1] for x in names])
+ self.assertEqual(len(names), 2, "2 vendor references should have been found")
+ self.assertEqual(ref, {'[A] Boudin', '[NO] Boudin'}, "Incorrect vendor reference list")
+ names = self.product_consu.with_context(
+ partner_id=self.asustec.id,
+ company_id=company_b.id,
+ ).name_get()
+ ref = set([x[1] for x in names])
+ self.assertEqual(len(names), 2, "2 vendor references should have been found")
+ self.assertEqual(ref, {'[B] Boudin', '[NO] Boudin'}, "Incorrect vendor reference list")
diff --git a/addons/product/views/product_views.xml b/addons/product/views/product_views.xml
index b30c3d89c57..0afcfcfa727 100644
--- a/addons/product/views/product_views.xml
+++ b/addons/product/views/product_views.xml
@@ -332,8 +332,11 @@
<group name="packaging" position="attributes">
<attribute name="attrs">{'invisible': 0}</attribute>
</group>
+ <field name="name" position="attributes">
+ <attribute name="class">oe_edit_only</attribute>
+ </field>
<field name="name" position="after">
- <field name="product_tmpl_id" class="oe_inline" readonly="1" invisible="1" attrs="{'required': [('id', '!=', False)]}"/>
+ <field name="product_tmpl_id" class="oe_read_only oe_inline" readonly="1" attrs="{'required': [('id', '!=', False)]}"/>
</field>
<field name="item_ids" position="inside">
<field name="pricelist_item_ids" widget="many2many">
diff --git a/addons/project/models/project.py b/addons/project/models/project.py
index b3286c2afe3..658444f2023 100644
--- a/addons/project/models/project.py
+++ b/addons/project/models/project.py
@@ -185,9 +185,8 @@ class Project(models.Model):
default=_get_default_favorite_user_ids,
string='Members')
is_favorite = fields.Boolean(compute='_compute_is_favorite', inverse='_inverse_is_favorite', string='Show Project on dashboard',
- help="Whether this project should be displayed on the dashboard or not")
- label_tasks = fields.Char(string='Use Tasks as', default=lambda s: _('Tasks'), translate=True,
- help="Gives label to tasks on project's kanban view.")
+ help="Whether this project should be displayed on your dashboard.")
+ label_tasks = fields.Char(string='Use Tasks as', default='Tasks', help="Label used for the tasks of the project.")
tasks = fields.One2many('project.task', 'project_id', string="Task Activities")
resource_calendar_id = fields.Many2one(
'resource.calendar', string='Working Time',
@@ -209,27 +208,26 @@ class Project(models.Model):
],
string='Privacy', required=True,
default='portal',
- help="Holds visibility of the tasks or issues that belong to the current project:\n"
- "- On invitation only: Employees may only see the followed project, tasks or issues\n"
- "- Visible by all employees: Employees may see all project, tasks or issues\n"
- "- Visible by following customers: employees see everything;\n"
- " if website is activated, portal users may see project, tasks or issues followed by\n"
- " them or by someone of their company\n")
+ help="Defines the visibility of the tasks of the project:\n"
+ "- On invitation only: employees may only see the followed project and tasks.\n"
+ "- Visible by all employees: employees may see all project and tasks.\n"
+ "- Visible by following customers: employees may see everything."
+ " if website is activated, portal users may see project and tasks followed by.\n"
+ " them or by someone of their company.")
doc_count = fields.Integer(compute='_compute_attached_docs_count', string="Number of documents attached")
date_start = fields.Date(string='Start Date')
date = fields.Date(string='Expiration Date', index=True, track_visibility='onchange')
subtask_project_id = fields.Many2one('project.project', string='Sub-task Project', ondelete="restrict",
- help="Choosing a sub-tasks project will both enable sub-tasks and set their default project (possibly the project itself)")
+ help="Project in which sub-tasks of the current project will be created. It can be the current project itself.")
# rating fields
percentage_satisfaction_task = fields.Integer(
- compute='_compute_percentage_satisfaction_task', string="Happy % on Task", store=True, default=-1)
- percentage_satisfaction_project = fields.Integer(
- compute="_compute_percentage_satisfaction_project", string="Happy % on Project", store=True, default=-1)
+ compute='_compute_percentage_satisfaction_task', string="Happy % on Task", help="Satisfaction rate on task.", store=True, default=-1)
+ percentage_satisfaction_project = fields.Integer(compute="_compute_percentage_satisfaction_project", string="Happy % on Project", help="Satisfaction rate on project.", store=True, default=-1)
rating_request_deadline = fields.Datetime(compute='_compute_rating_request_deadline', store=True)
- rating_status = fields.Selection([('stage', 'Rating when changing stage'), ('periodic', 'Periodical Rating'), ('no','No rating')], 'Customer(s) Ratings', help="How to get the customer's feedbacks?\n"
- "- Rating when changing stage: Email will be sent when a task/issue is pulled in another stage\n"
- "- Periodical Rating: Email will be sent periodically\n\n"
+ rating_status = fields.Selection([('stage', 'Rating when changing stage'), ('periodic', 'Periodical Rating'), ('no','No rating')], 'Customer(s) Ratings', help="How to get customer feedback?\n"
+ "- Rating when changing stage: an email will be sent when a task is pulled in another stage.\n"
+ "- Periodical Rating: email will be sent periodically.\n\n"
"Don't forget to set up the mail templates on the stages for which you want to get the customer's feedbacks.", default="no", required=True)
rating_status_period = fields.Selection([
('daily', 'Daily'), ('weekly', 'Weekly'), ('bimonthly', 'Twice a Month'),
@@ -777,13 +775,13 @@ class Task(models.Model):
def _track_subtype(self, init_values):
self.ensure_one()
if 'kanban_state_label' in init_values and self.kanban_state == 'blocked':
- return 'project.mt_task_blocked'
+ return self.env.ref('project.mt_task_blocked')
elif 'kanban_state_label' in init_values and self.kanban_state == 'done':
- return 'project.mt_task_ready'
+ return self.env.ref('project.mt_task_ready')
elif 'stage_id' in init_values and self.stage_id and self.stage_id.sequence <= 1: # start stage -> new
- return 'project.mt_task_new'
+ return self.env.ref('project.mt_task_new')
elif 'stage_id' in init_values:
- return 'project.mt_task_stage'
+ return self.env.ref('project.mt_task_stage')
return super(Task, self)._track_subtype(init_values)
@api.multi
diff --git a/addons/project/views/project_views.xml b/addons/project/views/project_views.xml
index d262610462c..aa4e8be7704 100644
--- a/addons/project/views/project_views.xml
+++ b/addons/project/views/project_views.xml
@@ -18,7 +18,7 @@
<search string="Tasks">
<field name="name" string="Tasks"/>
<field name="tag_ids"/>
- <field name="partner_id"/>
+ <field name="partner_id" operator="child_of"/>
<field name="project_id"/>
<field name="user_id"/>
<field name="stage_id"/>
@@ -28,6 +28,8 @@
<separator/>
<filter string="Starred" name="starred" domain="[('priority','in',[1, 2])]"/>
<separator/>
+ <filter string="Opened" name="opened" domain="[('stage_id.fold','=',False)]"/>
+ <separator/>
<filter string="Unread Messages" name="message_needaction" domain="[('message_needaction','=',True)]"/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
<separator/>
@@ -865,17 +867,21 @@
</search>
</field>
</record>
+
<record model="ir.ui.view" id="project_tags_form_view">
<field name="name">Tags</field>
<field name="model">project.tags</field>
<field name="arch" type="xml">
<form string="Tags">
- <group>
- <field name="name"/>
- </group>
+ <sheet>
+ <group>
+ <field name="name"/>
+ </group>
+ </sheet>
</form>
</field>
</record>
+
<record id="project_tags_action" model="ir.actions.act_window">
<field name="name">Tags</field>
<field name="res_model">project.tags</field>
diff --git a/addons/purchase/models/purchase.py b/addons/purchase/models/purchase.py
index 9b13a39b6e3..2bbf930f3c8 100644
--- a/addons/purchase/models/purchase.py
+++ b/addons/purchase/models/purchase.py
@@ -179,11 +179,11 @@ class PurchaseOrder(models.Model):
def _track_subtype(self, init_values):
self.ensure_one()
if 'state' in init_values and self.state == 'purchase':
- return 'purchase.mt_rfq_approved'
+ return self.env.ref('purchase.mt_rfq_approved')
elif 'state' in init_values and self.state == 'to approve':
- return 'purchase.mt_rfq_confirmed'
+ return self.env.ref('purchase.mt_rfq_confirmed')
elif 'state' in init_values and self.state == 'done':
- return 'purchase.mt_rfq_done'
+ return self.env.ref('purchase.mt_rfq_done')
return super(PurchaseOrder, self)._track_subtype(init_values)
@api.onchange('partner_id', 'company_id')
@@ -427,7 +427,13 @@ class PurchaseOrderLine(models.Model):
# Replace by invoiced Qty
qty_invoiced = fields.Float(compute='_compute_qty_invoiced', string="Billed Qty", digits=dp.get_precision('Product Unit of Measure'), store=True)
- qty_received = fields.Float(string="Received Qty", digits=dp.get_precision('Product Unit of Measure'), copy=False)
+
+ qty_received_method = fields.Selection([('manual', 'Manual')], string="Received Qty Method", compute='_compute_qty_received_method', store=True,
+ help="According to product configuration, the recieved quantity can be automatically computed by mechanism :\n"
+ " - Manual: the quantity is set manually on the line\n"
+ " - Stock Moves: the quantity comes from confirmed pickings\n")
+ qty_received = fields.Float("Received Qty", compute='_compute_qty_received', inverse='_inverse_qty_received', compute_sudo=True, store=True, digits=dp.get_precision('Product Unit of Measure'))
+ qty_received_manual = fields.Float("Manual Received Qty", digits=dp.get_precision('Product Unit of Measure'), copy=False)
partner_id = fields.Many2one('res.partner', related='order_id.partner_id', string='Partner', readonly=True, store=True)
currency_id = fields.Many2one(related='order_id.currency_id', store=True, string='Currency', readonly=True)
@@ -484,6 +490,33 @@ class PurchaseOrderLine(models.Model):
qty -= inv_line.uom_id._compute_quantity(inv_line.quantity, line.product_uom)
line.qty_invoiced = qty
+ @api.multi
+ @api.depends('product_id')
+ def _compute_qty_received_method(self):
+ for line in self:
+ if line.product_id.type in ['consu', 'service']:
+ line.qty_received_method = 'manual'
+
+ @api.multi
+ @api.depends('qty_received_method', 'qty_received_manual')
+ def _compute_qty_received(self):
+ for line in self:
+ if line.qty_received_method == 'manual':
+ line.qty_received = line.qty_received_manual or 0.0
+
+ @api.multi
+ @api.onchange('qty_received')
+ def _inverse_qty_received(self):
+ """ When writing on qty_received, if the value should be modify manually (`qty_received_method` = 'manual' only),
+ then we put the value in `qty_received_manual`. Otherwise, `qty_received_manual` should be False since the
+ received qty is automatically compute by other mecanisms.
+ """
+ for line in self:
+ if line.qty_received_method == 'manual':
+ line.qty_received_manual = line.qty_received
+ else:
+ line.qty_received_manual = 0.0
+
@api.model
def create(self, values):
line = super(PurchaseOrderLine, self).create(values)
@@ -543,6 +576,7 @@ class PurchaseOrderLine(models.Model):
product_lang = self.product_id.with_context(
lang=self.partner_id.lang,
partner_id=self.partner_id.id,
+ company_id=self.company_id.id,
)
self.name = product_lang.display_name
if product_lang.description_purchase:
diff --git a/addons/purchase/models/res_partner.py b/addons/purchase/models/res_partner.py
index dd120858885..75b849af619 100644
--- a/addons/purchase/models/res_partner.py
+++ b/addons/purchase/models/res_partner.py
@@ -19,7 +19,7 @@ class res_partner(models.Model):
def _compute_supplier_invoice_count(self):
Invoice = self.env['account.invoice']
for partner in self:
- partner.supplier_invoice_count = Invoice.search_count([('partner_id', 'child_of', partner.id), ('type', '=', 'in_invoice')])
+ partner.supplier_invoice_count = Invoice.search_count([('partner_id', 'child_of', partner.id), ('type', 'in', ('in_invoice', 'in_refund'))])
@api.model
def _commercial_fields(self):
diff --git a/addons/purchase/views/purchase_views.xml b/addons/purchase/views/purchase_views.xml
index 5679d565c9c..6ec235e341a 100644
--- a/addons/purchase/views/purchase_views.xml
+++ b/addons/purchase/views/purchase_views.xml
@@ -192,7 +192,9 @@
<field name="account_analytic_id" context="{'default_partner_id':parent.partner_id}" groups="analytic.group_analytic_accounting"/>
<field name="analytic_tag_ids" groups="analytic.group_analytic_tags" widget="many2many_tags" options="{'color_field': 'color'}"/>
<field name="product_qty"/>
- <field name="qty_received" attrs="{'column_invisible': [('parent.state', 'not in', ('purchase', 'done'))]}"/>
+ <field name="qty_received_manual" invisible="1"/>
+ <field name="qty_received_method" invisible="1"/>
+ <field name="qty_received" attrs="{'column_invisible': [('parent.state', 'not in', ('purchase', 'done'))], 'readonly': [('qty_received_method', '!=', 'manual')]}"/>
<field name="qty_invoiced" attrs="{'column_invisible': [('parent.state', 'not in', ('purchase', 'done'))]}"/>
<field name="product_uom" groups="uom.group_uom" attrs="{'readonly': [('state', 'in', ('purchase', 'done', 'cancel'))]}" force_save="1"/>
<field name="price_unit"/>
diff --git a/addons/purchase/views/res_partner_views.xml b/addons/purchase/views/res_partner_views.xml
index d125006be9a..39d2f994767 100644
--- a/addons/purchase/views/res_partner_views.xml
+++ b/addons/purchase/views/res_partner_views.xml
@@ -52,7 +52,7 @@
<field name="res_model">account.invoice</field>
<field name="view_type">form</field>
<field name="view_mode">tree,form,graph</field>
- <field name="domain">[('type','=','in_invoice')]</field>
+ <field name="domain">[('type','in',('in_invoice', 'in_refund'))]</field>
<field name="context">{'search_default_partner_id': active_id, 'default_type': 'in_invoice', 'type': 'in_invoice', 'journal_type': 'purchase'}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
diff --git a/addons/purchase_mrp/models/purchase_mrp.py b/addons/purchase_mrp/models/purchase_mrp.py
index c554d64e152..176c86cb358 100644
--- a/addons/purchase_mrp/models/purchase_mrp.py
+++ b/addons/purchase_mrp/models/purchase_mrp.py
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from odoo import fields, models
+from odoo import api, fields, models
from odoo.tools import float_compare
@@ -14,12 +14,14 @@ class MrpProduction(models.Model):
class PurchaseOrderLine(models.Model):
_inherit = 'purchase.order.line'
- def _update_received_qty(self):
- super(PurchaseOrderLine, self)._update_received_qty()
- for line in self.filtered(lambda x: x.move_ids and x.product_id.id not in x.move_ids.mapped('product_id').ids):
- bom = self.env['mrp.bom']._bom_find(product=line.product_id, company_id=line.company_id.id)
- if bom and bom.type == 'phantom':
- line.qty_received = line._get_bom_delivered(bom=bom)
+ @api.multi
+ def _compute_qty_received(self):
+ super(PurchaseOrderLine, self)._compute_qty_received()
+ for line in self:
+ if line.qty_received_method == 'stock_moves' and line.move_ids and line.product_id.id not in line.move_ids.mapped('product_id').ids:
+ bom = self.env['mrp.bom']._bom_find(product=line.product_id, company_id=line.company_id.id)
+ if bom and bom.type == 'phantom':
+ line.qty_received = line._get_bom_delivered(bom=bom)
def _get_bom_delivered(self, bom=False):
self.ensure_one()
diff --git a/addons/purchase_requisition/data/purchase_requisition_data.xml b/addons/purchase_requisition/data/purchase_requisition_data.xml
index 0b43a332697..84194b8d89b 100644
--- a/addons/purchase_requisition/data/purchase_requisition_data.xml
+++ b/addons/purchase_requisition/data/purchase_requisition_data.xml
@@ -2,7 +2,7 @@
<odoo>
<data noupdate="1">
- <function id="purchase_default_set"
+ <function
model="ir.default" name="set"
eval="('purchase.requisition', 'warehouse_id', ref('stock.warehouse0'))"/>
diff --git a/addons/purchase_stock/models/purchase.py b/addons/purchase_stock/models/purchase.py
index 5d923719ebb..3cc797255cd 100644
--- a/addons/purchase_stock/models/purchase.py
+++ b/addons/purchase_stock/models/purchase.py
@@ -221,10 +221,35 @@ class PurchaseOrder(models.Model):
class PurchaseOrderLine(models.Model):
_inherit = 'purchase.order.line'
+ qty_received_method = fields.Selection(selection_add=[('stock_moves', 'Stock Moves')])
+
move_ids = fields.One2many('stock.move', 'purchase_line_id', string='Reservation', readonly=True, ondelete='set null', copy=False)
orderpoint_id = fields.Many2one('stock.warehouse.orderpoint', 'Orderpoint')
move_dest_ids = fields.One2many('stock.move', 'created_purchase_line_id', 'Downstream Moves')
+ @api.multi
+ def _compute_qty_received_method(self):
+ super(PurchaseOrderLine, self)._compute_qty_received_method()
+ for line in self:
+ if line.product_id.type in ['consu', 'product']:
+ line.qty_received_method = 'stock_moves'
+
+ @api.multi
+ @api.depends('move_ids.state', 'move_ids.product_uom_qty', 'move_ids.product_uom')
+ def _compute_qty_received(self):
+ super(PurchaseOrderLine, self)._compute_qty_received()
+ for line in self:
+ if line.qty_received_method == 'stock_moves':
+ total = 0.0
+ for move in line.move_ids:
+ if move.state == 'done':
+ if move.location_dest_id.usage == "supplier":
+ if move.to_refund:
+ total -= move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom)
+ else:
+ total += move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom)
+ line.qty_received = total
+
@api.model
def create(self, values):
line = super(PurchaseOrderLine, self).create(values)
@@ -354,18 +379,6 @@ class PurchaseOrderLine(models.Model):
done += moves.create(val)
return done
- def _update_received_qty(self):
- for line in self:
- total = 0.0
- for move in line.move_ids:
- if move.state == 'done':
- if move.location_dest_id.usage == "supplier":
- if move.to_refund:
- total -= move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom)
- else:
- total += move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom)
- line.qty_received = total
-
def _merge_in_existing_line(self, product_id, product_qty, product_uom, location_id, name, origin, values):
""" This function purpose is to be override with the purpose to forbide _run_buy method
to merge a new po line in an existing one.
diff --git a/addons/purchase_stock/models/stock.py b/addons/purchase_stock/models/stock.py
index 0f6a1ff0ac3..6fefdbb3335 100644
--- a/addons/purchase_stock/models/stock.py
+++ b/addons/purchase_stock/models/stock.py
@@ -82,18 +82,6 @@ class StockMove(models.Model):
super(StockMove, self)._clean_merged()
self.write({'created_purchase_line_id': False})
- def _action_done(self):
- res = super(StockMove, self)._action_done()
- self.mapped('purchase_line_id').sudo()._update_received_qty()
- return res
-
- def write(self, vals):
- res = super(StockMove, self).write(vals)
- if 'product_uom_qty' in vals:
- self.filtered(lambda m: m.state == 'done' and m.purchase_line_id).mapped(
- 'purchase_line_id').sudo()._update_received_qty()
- return res
-
def _get_upstream_documents_and_responsibles(self, visited):
if self.created_purchase_line_id and self.created_purchase_line_id.state not in ('done', 'cancel'):
return [(self.created_purchase_line_id.order_id, self.created_purchase_line_id.order_id.user_id, visited)]
diff --git a/addons/repair/data/repair_demo.xml b/addons/repair/data/repair_demo.xml
index 60616df3fb2..173c86f863b 100644
--- a/addons/repair/data/repair_demo.xml
+++ b/addons/repair/data/repair_demo.xml
@@ -9,7 +9,7 @@
<field name="partner_invoice_id" ref="base.res_partner_address_1"/>
<field name="location_id" ref="stock.stock_location_stock"/>
<field name="operations" model="repair.line" eval="[(5, 0, 0), (0, 0, {
- 'location_dest_id': obj().env.ref('stock.location_production').id,
+ 'location_dest_id': obj().env.ref('product.product_product_11').property_stock_production.id,
'location_id': obj().env.ref('stock.stock_location_stock').id,
'name': '[M-Las] Mouse, Laser',
'product_id': obj().env.ref('product.product_product_11').id,
@@ -38,7 +38,7 @@
<field name="partner_invoice_id" ref="base.res_partner_address_1"/>
<field name="location_id" ref="stock.stock_location_stock"/>
<field name="operations" model="repair.line" eval="[(5, 0, 0), (0, 0, {
- 'location_dest_id': obj().env.ref('stock.location_production').id,
+ 'location_dest_id': obj().env.ref('product.product_product_12').property_stock_production.id,
'location_id': obj().env.ref('stock.stock_location_stock').id,
'name': '[M-Wir] Mouse, Wireless',
'price_unit': 50.0,
@@ -67,7 +67,7 @@
<field name="partner_invoice_id" ref="base.res_partner_address_1"/>
<field name="location_id" ref="stock.stock_location_14"/>
<field name="operations" model="repair.line" eval="[(5, 0, 0), (0, 0, {
- 'location_dest_id': obj().env.ref('stock.location_production').id,
+ 'location_dest_id': obj().env.ref('product.product_product_13').property_stock_production.id,
'location_id': obj().env.ref('stock.stock_location_stock').id,
'name': '[RAM-SR5] RAM DDR SR5',
'price_unit': 50.0,
diff --git a/addons/repair/tests/test_repair.py b/addons/repair/tests/test_repair.py
index 568ba8abce6..17b19f86f98 100644
--- a/addons/repair/tests/test_repair.py
+++ b/addons/repair/tests/test_repair.py
@@ -59,7 +59,7 @@ class TestRepair(AccountingTestCase):
'price_unit': price_unit,
'repair_id': repair_id,
'location_id': self.env.ref('stock.stock_location_stock').id,
- 'location_dest_id': self.env.ref('stock.location_production').id,
+ 'location_dest_id': product_to_add.property_stock_production.id,
})
def _create_simple_fee(self, repair_id=False, qty=0.0, price_unit=0.0):
@@ -145,7 +145,7 @@ class TestRepair(AccountingTestCase):
'Repaired product went to the wrong location')
self.assertEqual(repair.operations.move_id.location_id.id, self.env.ref('stock.stock_location_stock').id,
'Consumed product was taken in the wrong location')
- self.assertEqual(repair.operations.move_id.location_dest_id.id, self.env.ref('stock.location_production').id,
+ self.assertEqual(repair.operations.move_id.location_dest_id.id, self.env.ref('product.product_product_5').property_stock_production.id,
'Consumed product went to the wrong location')
# I define Invoice Method 'No Invoice' option in this repair order.
diff --git a/addons/resource/data/resource_data.xml b/addons/resource/data/resource_data.xml
index 52bd5f1895e..1390e0bd518 100644
--- a/addons/resource/data/resource_data.xml
+++ b/addons/resource/data/resource_data.xml
@@ -12,7 +12,7 @@
<field name="resource_calendar_id" ref="resource_calendar_std"/>
</record>
- <function id="_init_data_resource_calendar"
+ <function
model="res.company"
name="_init_data_resource_calendar"
eval="[]"/>
diff --git a/addons/sale/models/__init__.py b/addons/sale/models/__init__.py
index 1f6f027cba3..e2e2d1b5c66 100644
--- a/addons/sale/models/__init__.py
+++ b/addons/sale/models/__init__.py
@@ -3,7 +3,6 @@
from . import analytic
from . import account_invoice
-from . import product_pricelist
from . import product_product
from . import product_template
from . import res_company
diff --git a/addons/sale/models/product_pricelist.py b/addons/sale/models/product_pricelist.py
deleted file mode 100644
index 36e84ba0fbd..00000000000
--- a/addons/sale/models/product_pricelist.py
+++ /dev/null
@@ -1,13 +0,0 @@
-# -*- coding: utf-8 -*-
-# Part of Odoo. See LICENSE file for full copyright and licensing details.
-
-from odoo import fields, models
-
-
-class ProductPricelist(models.Model):
- _inherit = 'product.pricelist'
-
- discount_policy = fields.Selection([
- ('with_discount', 'Discount included in the price'),
- ('without_discount', 'Show public price & discount to the customer')],
- default='with_discount')
diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py
index 1169b137e1c..a09fa8711e8 100644
--- a/addons/sale/models/sale.py
+++ b/addons/sale/models/sale.py
@@ -141,8 +141,8 @@ class SaleOrder(models.Model):
('cancel', 'Cancelled'),
], string='Status', readonly=True, copy=False, index=True, track_visibility='onchange', track_sequence=3, default='draft')
date_order = fields.Datetime(string='Order Date', required=True, readonly=True, index=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, copy=False, default=fields.Datetime.now)
- validity_date = fields.Date(string='Validity', readonly=True, copy=False, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]},
- help="Validity date of the quotation, after this date, the customer won't be able to validate the quotation online.", default=_default_validity_date)
+ validity_date = fields.Date(string='Expiration', readonly=True, copy=False, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]},
+ help="Expiration date of the quotation, after this date, the customer won't be able to validate the quotation online.", default=_default_validity_date)
is_expired = fields.Boolean(compute='_compute_is_expired', string="Is expired")
require_signature = fields.Boolean('Online Signature', default=_get_default_require_signature, readonly=True,
states={'draft': [('readonly', False)], 'sent': [('readonly', False)]},
@@ -150,7 +150,7 @@ class SaleOrder(models.Model):
require_payment = fields.Boolean('Online Payment', default=_get_default_require_payment, readonly=True,
states={'draft': [('readonly', False)], 'sent': [('readonly', False)]},
help='Request an online payment to the customer in order to confirm orders automatically.')
- remaining_validity_days = fields.Integer(compute='_compute_remaining_validity_days', string="Remaining Validity Days")
+ remaining_validity_days = fields.Integer(compute='_compute_remaining_validity_days', string="Remaining Days Before Expiration")
create_date = fields.Datetime(string='Creation Date', readonly=True, index=True, help="Date on which sales order is created.")
confirmation_date = fields.Datetime(string='Confirmation Date', readonly=True, index=True, help="Date on which the sales order is confirmed.", oldname="date_confirm", copy=False)
user_id = fields.Many2one('res.users', string='Salesperson', index=True, track_visibility='onchange', track_sequence=2, default=lambda self: self.env.user)
@@ -272,9 +272,9 @@ class SaleOrder(models.Model):
def _track_subtype(self, init_values):
self.ensure_one()
if 'state' in init_values and self.state == 'sale':
- return 'sale.mt_order_confirmed'
+ return self.env.ref('sale.mt_order_confirmed')
elif 'state' in init_values and self.state == 'sent':
- return 'sale.mt_order_sent'
+ return self.env.ref('sale.mt_order_sent')
return super(SaleOrder, self)._track_subtype(init_values)
@api.multi
diff --git a/addons/sale/wizard/sale_make_invoice_advance.py b/addons/sale/wizard/sale_make_invoice_advance.py
index f004f48e079..2914a99fa08 100644
--- a/addons/sale/wizard/sale_make_invoice_advance.py
+++ b/addons/sale/wizard/sale_make_invoice_advance.py
@@ -162,7 +162,7 @@ class SaleAdvancePaymentInv(models.TransientModel):
for line in order.order_line:
analytic_tag_ids = [(4, analytic_tag.id, None) for analytic_tag in line.analytic_tag_ids]
so_line = sale_line_obj.create({
- 'name': _('Advance: %s') % (time.strftime('%m %Y'),),
+ 'name': _('Down Payment: %s') % (time.strftime('%m %Y'),),
'price_unit': amount,
'product_uom_qty': 0.0,
'order_id': order.id,
diff --git a/addons/sale_management/models/sale_order_template.py b/addons/sale_management/models/sale_order_template.py
index a81c103415b..45bcf2bb752 100644
--- a/addons/sale_management/models/sale_order_template.py
+++ b/addons/sale_management/models/sale_order_template.py
@@ -64,7 +64,7 @@ class SaleOrderTemplateLine(models.Model):
def _onchange_product_id(self):
self.ensure_one()
if self.product_id:
- name = self.product_id.name_get()[0][1]
+ name = self.product_id.display_name
if self.product_id.description_sale:
name += '\n' + self.product_id.description_sale
self.name = name
diff --git a/addons/sale_management/views/sale_order_views.xml b/addons/sale_management/views/sale_order_views.xml
index 0c80f4441dd..cda0c8fdd74 100644
--- a/addons/sale_management/views/sale_order_views.xml
+++ b/addons/sale_management/views/sale_order_views.xml
@@ -7,7 +7,7 @@
<field name="arch" type="xml">
<xpath expr="//page/field[@name='order_line']/.." position="after">
- <page string="Optional Products">
+ <page string="Optional Products" name="optional_products">
<field name="sale_order_option_ids">
<tree string="Sales Quotation Template Lines" editable="bottom">
<control>
diff --git a/addons/sale_stock/views/stock_config_settings_views.xml b/addons/sale_stock/views/stock_config_settings_views.xml
deleted file mode 100644
index 8d2cfd7a396..00000000000
--- a/addons/sale_stock/views/stock_config_settings_views.xml
+++ /dev/null
@@ -1,59 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<odoo>
-
- <record id="stock_config_settings_view_form_inherit_sale_stock" model="ir.ui.view">
- <field name="name">stock.config.settings.inherit.sale.stock</field>
- <field name="model">stock.config.settings</field>
- <field name="inherit_id" ref="stock.view_stock_config_settings"/>
- <field name="arch" type="xml">
- <div id="warning_info" position="after">
- <div class="col-12 col-lg-6 o_setting_box" title="Reserving products manually in delivery orders or by running the scheduler is advised to better manage priorities in case of long customer lead times or/and frequent stock-outs. By default, the scheduler runs automatically every 24 hours.">
- <div class="o_setting_right_pane">
- <label for="module_procurement_jit"/>
- <div class="text-muted">
- When to reserve sold products
- </div>
- <div class="content-group">
- <div class="mt16">
- <field name="module_procurement_jit" class="o_light_label" widget="radio"/>
- </div>
- </div>
- </div>
- </div>
- <div class="col-12 col-lg-6 o_setting_box">
- <div class="o_setting_right_pane">
- <label for="default_picking_policy"/>
- <div class="text-muted">
- When to start shipping
- </div>
- <div class="content-group">
- <div class="mt16">
- <field name="default_picking_policy" class="o_light_label" widget="selection"/>
- </div>
- </div>
- </div>
- </div>
- </div>
- <div id="sale_security_lead" position="replace">
- <div class="col-12 col-lg-6 o_setting_box" title="Margin of error for dates promised to customers. Products will be scheduled for procurement and delivery that many days earlier than the actual promised date, to cope with unexpected delays in the supply chain.">
- <div class="o_setting_left_pane">
- <field name="use_security_lead"/>
- </div>
- <div class="o_setting_right_pane">
- <label for="use_security_lead"/>
- <span class="fa fa-lg fa-building-o" title="Values set here are company-specific." role="img" aria-label="Values set here are company-specific." groups="base.group_multi_company"/>
- <div class="text-muted">
- Schedule deliveries earlier to avoid delays
- </div>
- <div class="content-group">
- <div class="mt16" attrs="{'invisible': [('use_security_lead','=',False)]}">
- <span>Move forward expected delivery dates by <field name="security_lead" class="oe_inline"/> days</span>
- </div>
- </div>
- </div>
- </div>
- </div>
- </field>
- </record>
-
-</odoo>
diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py
index f2e7a7ee275..aa2b573f8d0 100644
--- a/addons/sale_timesheet/models/project.py
+++ b/addons/sale_timesheet/models/project.py
@@ -8,18 +8,23 @@ from odoo.exceptions import ValidationError
class Project(models.Model):
_inherit = 'project.project'
- sale_line_id = fields.Many2one('sale.order.line', 'Sales Order Item', domain="[('is_expense', '=', False), ('order_id', '=', sale_order_id), ('state', 'in', ['sale', 'done'])]", copy=False, help="Sale order line from which the project has been created. Used for tracability.")
- sale_order_id = fields.Many2one('sale.order', 'Sales Order', domain="[('partner_id', '=', partner_id)]", readonly=True, copy=False)
+ sale_line_id = fields.Many2one('sale.order.line', 'Sales Order Item', domain="[('is_expense', '=', False), ('order_id', '=', sale_order_id), ('state', 'in', ['sale', 'done'])]", copy=False,
+ help="Sales order item to which the project is linked. If an employee timesheets on a task that does not have a "
+ "sale order item defines, and if this employee is not in the 'Employee/Sales Order Item Mapping' of the project, "
+ "the timesheet entry will be linked to the sales order item defined on the project.")
+ sale_order_id = fields.Many2one('sale.order', 'Sales Order', domain="[('partner_id', '=', partner_id)]", readonly=True, copy=False, help="Sales order to which the project is linked.")
billable_type = fields.Selection([
('task_rate', 'At Task Rate'),
('employee_rate', 'At Employee Rate'),
('no', 'No Billable')
], string="Billable Type", compute='_compute_billable_type', compute_sudo=True, store=True,
- help='Billable type implies:\n'
+ help='At which rate timesheets will be billed:\n'
' - At task rate: each time spend on a task is billed at task rate.\n'
' - At employee rate: each employee log time billed at his rate.\n'
' - No Billable: track time without invoicing it')
- sale_line_employee_ids = fields.One2many('project.sale.line.employee.map', 'project_id', "Sale line/Employee map", copy=False)
+ sale_line_employee_ids = fields.One2many('project.sale.line.employee.map', 'project_id', "Sale line/Employee map", copy=False,
+ help="Employee/Sale Order Item Mapping:\n Defines to which sales order item an employee's timesheet entry will be linked."
+ "By extension, it defines the rate at which an employee's time on the project is billed.")
_sql_constraints = [
('sale_order_required_if_sale_line', "CHECK((sale_line_id IS NOT NULL AND sale_order_id IS NOT NULL) OR (sale_line_id IS NULL))", 'The Project should be linked to a Sale Order to select an Sale Order Items.'),
@@ -142,8 +147,11 @@ class ProjectTask(models.Model):
sale_line_id = project.sale_line_id.id
return sale_line_id
- sale_line_id = fields.Many2one('sale.order.line', 'Sales Order Item', default=_default_sale_line_id, domain="[('is_service', '=', True), ('order_partner_id', '=', partner_id), ('is_expense', '=', False), ('state', 'in', ['sale', 'done'])]")
- sale_order_id = fields.Many2one('sale.order', 'Sales Order', compute='_compute_sale_order_id', compute_sudo=True, store=True, readonly=True)
+ sale_line_id = fields.Many2one('sale.order.line', 'Sales Order Item', default=_default_sale_line_id, domain="[('is_service', '=', True), ('order_partner_id', '=', partner_id), ('is_expense', '=', False), ('state', 'in', ['sale', 'done'])]",
+ help="Sales order item to which the task is linked. If an employee timesheets on a this task, "
+ "and if this employee is not in the 'Employee/Sales Order Item Mapping' of the project, the "
+ "timesheet entry will be linked to this sales order item.")
+ sale_order_id = fields.Many2one('sale.order', 'Sales Order', compute='_compute_sale_order_id', compute_sudo=True, store=True, readonly=True, help="Sales order to which the task is linked.")
billable_type = fields.Selection([
('task_rate', 'At Task Rate'),
('employee_rate', 'At Employee Rate'),
diff --git a/addons/sms/wizard/send_sms.py b/addons/sms/wizard/send_sms.py
index 448d9fcc626..786a5e2af76 100644
--- a/addons/sms/wizard/send_sms.py
+++ b/addons/sms/wizard/send_sms.py
@@ -67,7 +67,7 @@ class SendSMS(models.TransientModel):
model = self.env[active_model]
records = self._get_records(model)
- if getattr(records, '_get_default_sms_recipients'):
+ if getattr(records, '_get_default_sms_recipients') and not self.env.context.get('default_recipients'):
partners = records._get_default_sms_recipients()
phone_numbers = []
no_phone_partners = []
diff --git a/addons/stock/__init__.py b/addons/stock/__init__.py
index a07046e3fd4..66322ee14f3 100644
--- a/addons/stock/__init__.py
+++ b/addons/stock/__init__.py
@@ -17,20 +17,3 @@ def pre_init_hook(cr):
('model', 'like', '%stock%'),
('module', '=', 'stock')
]).unlink()
-
-def _create_warehouse(cr, registry):
- """ This hook is used to add a warehouse on existing companies
- when module stock is installed.
- """
- env = api.Environment(cr, SUPERUSER_ID, {})
- company_ids = env['res.company'].search([])
- company_with_warehouse = env['stock.warehouse'].search([]).mapped('company_id')
- company_without_warehouse = company_ids - company_with_warehouse
- for company in company_without_warehouse:
- company.create_transit_location()
- env['stock.warehouse'].create({
- 'name': company.name,
- 'code': company.name[:5],
- 'company_id': company.id,
- 'partner_id': company.partner_id.id
- })
diff --git a/addons/stock/__manifest__.py b/addons/stock/__manifest__.py
index a6c42ce4111..eacc673c9fc 100644
--- a/addons/stock/__manifest__.py
+++ b/addons/stock/__manifest__.py
@@ -78,5 +78,4 @@
'application': True,
'auto_install': False,
'pre_init_hook': 'pre_init_hook',
- 'post_init_hook': '_create_warehouse',
}
diff --git a/addons/stock/data/stock_data.xml b/addons/stock/data/stock_data.xml
index e3487172f7c..be5f560d1a1 100644
--- a/addons/stock/data/stock_data.xml
+++ b/addons/stock/data/stock_data.xml
@@ -31,32 +31,13 @@
<field name="posz">1</field>
<field name="company_id"></field>
</record>
- <record id="stock_location_scrapped" model="stock.location">
- <field name="name">Scrapped</field>
- <field name="location_id" ref="stock_location_locations_virtual"/>
- <field name="scrap_location">True</field>
- <field name="usage">inventory</field>
- <field name="company_id"></field>
- </record>
- <record id="location_inventory" model="stock.location">
- <field name="name">Inventory adjustment</field>
- <field name="location_id" ref="stock_location_locations_virtual"/>
- <field name="usage">inventory</field>
- <field name="company_id"></field>
- </record>
<record id="location_procurement" model="stock.location">
<field name="name">Procurements</field>
<field name="location_id" ref="stock_location_locations_virtual"/>
<field name="usage">procurement</field>
<field name="company_id"></field>
</record>
- <record id="location_production" model="stock.location">
- <field name="name">Production</field>
- <field name="location_id" ref="stock_location_locations_virtual"/>
- <field name="usage">production</field>
- <field name="company_id"></field>
- </record>
<record id="stock_location_suppliers" model="stock.location">
<field name="name">Vendors</field>
<field name="location_id" ref="stock_location_locations_partner"/>
@@ -103,16 +84,6 @@
<field name="fields_id" search="[('model','=','res.partner'),('name','=','property_stock_customer')]"/>
<field eval="'stock.location,'+str(stock_location_customers)" name="value"/>
</record>
- <record forcecreate="True" id="property_stock_inventory" model="ir.property">
- <field name="name">property_stock_inventory</field>
- <field name="fields_id" search="[('model','=','product.template'),('name','=','property_stock_inventory')]"/>
- <field eval="'stock.location,'+str(location_inventory)" name="value"/>
- </record>
- <record forcecreate="True" id="property_stock_production" model="ir.property">
- <field name="name">property_stock_production</field>
- <field name="fields_id" search="[('model','=','product.template'),('name','=','property_stock_production')]"/>
- <field eval="'stock.location,'+str(location_production)" name="value"/>
- </record>
<!--
@@ -177,8 +148,20 @@
</function>
<!-- create the transit location for each company existing -->
- <function model="res.company" name="create_transit_location">
- <value model="res.company" eval="obj().search([('internal_transit_location_id', '=', False)]).ids"/>
+ <function model="res.company" name="create_missing_transit_location">
+ <value model="res.company" eval="obj()"/>
+ </function>
+ <function model="res.company" name="create_missing_warehouse">
+ <value model="res.company" eval="obj()"/>
+ </function>
+ <function model="res.company" name="create_missing_inventory_loss_location">
+ <value model="res.company" eval="obj()"/>
+ </function>
+ <function model="res.company" name="create_missing_production_location">
+ <value model="res.company" eval="obj()"/>
+ </function>
+ <function model="res.company" name="create_missing_scrap_location">
+ <value model="res.company" eval="obj()"/>
</function>
</data>
</odoo>
diff --git a/addons/stock/data/stock_demo2.xml b/addons/stock/data/stock_demo2.xml
index e5f23bb647d..0b8ff734243 100644
--- a/addons/stock/data/stock_demo2.xml
+++ b/addons/stock/data/stock_demo2.xml
@@ -34,7 +34,6 @@
<field name="tracking">lot</field>
<field name="uom_id" ref="uom.product_uom_kgm"/>
<field name="uom_po_id" ref="uom.product_uom_kgm"/>
- <field name="property_stock_inventory" ref="stock.location_inventory"/>
<field name="image" type="base64" file="stock/static/img/cable_management.png"/>
</record>
diff --git a/addons/stock/models/res_company.py b/addons/stock/models/res_company.py
index 28a10509d52..3fcbad0079f 100644
--- a/addons/stock/models/res_company.py
+++ b/addons/stock/models/res_company.py
@@ -12,34 +12,127 @@ class Company(models.Model):
'stock.location', 'Internal Transit Location', on_delete="restrict",
help="Technical field used for resupply routes between warehouses that belong to this company")
- def create_transit_location(self):
+ def _create_transit_location(self):
'''Create a transit location with company_id being the given company_id. This is needed
in case of resuply routes between warehouses belonging to the same company, because
we don't want to create accounting entries at that time.
'''
- # TDE FIXME: called in data - should be done in init ??
parent_location = self.env.ref('stock.stock_location_locations', raise_if_not_found=False)
for company in self:
location = self.env['stock.location'].create({
'name': _('%s: Transit Location') % company.name,
'usage': 'transit',
'location_id': parent_location and parent_location.id or False,
+ 'company_id': company.id,
})
- location.sudo().write({'company_id': company.id})
+
company.write({'internal_transit_location_id': location.id})
- warehouses = self.env['stock.warehouse'].search([('partner_id', '=', company.partner_id.id)])
- warehouses.mapped('partner_id').with_context(force_company=company.id).write({
+ company.partner_id.with_context(force_company=company.id).write({
'property_stock_customer': location.id,
'property_stock_supplier': location.id,
})
+ def _create_inventory_loss_location(self):
+ parent_location = self.env.ref('stock.stock_location_locations_virtual', raise_if_not_found=False)
+ inventory_loss_product_template_field = self.env['ir.model.fields'].search([('model','=','product.template'),('name','=','property_stock_inventory')])
+ for company in self:
+ inventory_loss_location = self.env['stock.location'].create({
+ 'name': '%s: Inventory adjustment' % company.name,
+ 'usage': 'inventory',
+ 'location_id': parent_location.id,
+ 'company_id': company.id,
+ })
+ self.env['ir.property'].create({
+ 'name': 'property_stock_inventory_%s' % company.name,
+ 'fields_id': inventory_loss_product_template_field.id,
+ 'company_id': company.id,
+ 'value': 'stock.location,%d' % inventory_loss_location.id,
+ })
+
+ def _create_production_location(self):
+ parent_location = self.env.ref('stock.stock_location_locations_virtual', raise_if_not_found=False)
+ production_product_template_field = self.env['ir.model.fields'].search([('model','=','product.template'),('name','=','property_stock_production')])
+ for company in self:
+ production_location = self.env['stock.location'].create({
+ 'name': '%s: Production' % company.name,
+ 'usage': 'production',
+ 'location_id': parent_location.id,
+ 'company_id': company.id,
+ })
+ self.env['ir.property'].create({
+ 'name': 'property_stock_inventory_%s' % company.name,
+ 'fields_id': production_product_template_field.id,
+ 'company_id': company.id,
+ 'value': 'stock.location,%d' % production_location.id,
+ })
+
+ def _create_scrap_location(self):
+ for company in self:
+ parent_location = self.env.ref('stock.stock_location_locations_virtual', raise_if_not_found=False)
+ scrap_location = self.env['stock.location'].create({
+ 'name': '%s: Scrap' % company.name,
+ 'usage': 'inventory',
+ 'location_id': parent_location.id,
+ 'company_id': company.id,
+ 'scrap_location': True,
+ })
+
+ @api.model
+ def create_missing_warehouse(self):
+ """ This hook is used to add a warehouse on existing companies
+ when module stock is installed.
+ """
+ company_ids = self.env['res.company'].search([])
+ company_with_warehouse = self.env['stock.warehouse'].search([]).mapped('company_id')
+ company_without_warehouse = company_ids - company_with_warehouse
+ for company in company_without_warehouse:
+ self.env['stock.warehouse'].create({
+ 'name': company.name,
+ 'code': company.name[:5],
+ 'company_id': company.id,
+ 'partner_id': company.partner_id.id,
+ })
+
+ @api.model
+ def create_missing_transit_location(self):
+ company_without_transit = self.env['res.company'].search([('internal_transit_location_id', '=', False)])
+ for company in company_without_transit:
+ company._create_transit_location()
+
+ @api.model
+ def create_missing_inventory_loss_location(self):
+ company_ids = self.env['res.company'].search([])
+ inventory_loss_product_template_field = self.env['ir.model.fields'].search([('model','=','product.template'),('name','=','property_stock_inventory')])
+ companies_having_property = self.env['ir.property'].search([('fields_id', '=', inventory_loss_product_template_field.id)]).mapped('company_id')
+ company_without_property = company_ids - companies_having_property
+ for company in company_without_property:
+ company._create_inventory_loss_location()
+
+ @api.model
+ def create_missing_production_location(self):
+ company_ids = self.env['res.company'].search([])
+ production_product_template_field = self.env['ir.model.fields'].search([('model','=','product.template'),('name','=','property_stock_production')])
+ companies_having_property = self.env['ir.property'].search([('fields_id', '=', production_product_template_field.id)]).mapped('company_id')
+ company_without_property = company_ids - companies_having_property
+ for company in company_without_property:
+ company._create_production_location()
+
+ @api.model
+ def create_missing_scrap_location(self):
+ company_ids = self.env['res.company'].search([])
+ companies_having_scrap_loc = self.env['stock.location'].search([('scrap_location', '=', True)]).mapped('company_id')
+ company_without_property = company_ids - companies_having_scrap_loc
+ for company in company_without_property:
+ company._create_scrap_location()
+
@api.model
def create(self, vals):
company = super(Company, self).create(vals)
- company.create_transit_location()
- # mutli-company rules prevents creating warehouse and sub-locations
- self.env['stock.warehouse'].check_access_rights('create')
+ company.sudo()._create_transit_location()
+ company.sudo()._create_inventory_loss_location()
+ company.sudo()._create_production_location()
+ company.sudo()._create_scrap_location()
self.env['stock.warehouse'].sudo().create({'name': company.name, 'code': company.name[:5], 'company_id': company.id, 'partner_id': company.partner_id.id})
return company
diff --git a/addons/stock/models/stock_inventory.py b/addons/stock/models/stock_inventory.py
index c63288d8b60..d564a851ad6 100644
--- a/addons/stock/models/stock_inventory.py
+++ b/addons/stock/models/stock_inventory.py
@@ -180,7 +180,7 @@ class Inventory(models.Model):
else:
self._action_done()
- def _action_done(self):
+ def _action_done(self, cancel_backorder=False):
negative = next((line for line in self.mapped('line_ids') if line.product_qty < 0 and line.product_qty != line.theoretical_qty), False)
if negative:
raise UserError(_('You cannot set a negative product quantity in an inventory line:\n\t%s - qty: %s') % (negative.product_id.name, negative.product_qty))
diff --git a/addons/stock/models/stock_location.py b/addons/stock/models/stock_location.py
index 336dbac47bb..6accdc3b01e 100644
--- a/addons/stock/models/stock_location.py
+++ b/addons/stock/models/stock_location.py
@@ -96,6 +96,17 @@ class Location(models.Model):
" location as there are products reserved in this location."
" Please unreserve the products first."
))
+ if 'active' in values:
+ if not self.env.context.get('do_not_check_quant'):
+ children_location = self.env['stock.location'].with_context(active_test=False).search([('id', 'child_of', self.ids)])
+ internal_children_locations = children_location.filtered(lambda l: l.usage == 'internal')
+ children_quants = self.env['stock.quant'].search([('quantity', '!=', 0), ('reserved_quantity', '!=', 0), ('location_id', 'in', internal_children_locations.ids)])
+ if children_quants and values['active'] == False:
+ raise UserError(_('You still have some product in locations %s') %
+ (','.join(children_quants.mapped('location_id.name'))))
+ else:
+ super(Location, children_location - self).with_context({'do_not_check_quant': True}).write(values)
+
return super(Location, self).write(values)
def name_get(self):
diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py
index 2513d6e3e62..33c64e74b28 100644
--- a/addons/stock/models/stock_move.py
+++ b/addons/stock/models/stock_move.py
@@ -1080,7 +1080,7 @@ class StockMove(models.Model):
def _unreserve_initial_demand(self, new_move):
pass
- def _action_done(self):
+ def _action_done(self, cancel_backorder=False):
self.filtered(lambda move: move.state == 'draft')._action_confirm() # MRP allows scrapping draft moves
moves = self.exists().filtered(lambda x: x.state not in ('done', 'cancel'))
moves_todo = self.env['stock.move']
@@ -1120,6 +1120,8 @@ class StockMove(models.Model):
except UserError:
pass
move._unreserve_initial_demand(new_move)
+ if cancel_backorder:
+ self.env['stock.move'].browse(new_move)._action_cancel()
moves_todo.mapped('move_line_ids')._action_done()
# Check the consistency of the result packages; there should be an unique location across
# the contained quants.
@@ -1136,7 +1138,7 @@ class StockMove(models.Model):
if all(move_todo.scrapped for move_todo in moves_todo):
return moves_todo
- if picking:
+ if picking and not cancel_backorder:
picking._create_backorder()
return moves_todo
diff --git a/addons/stock/models/stock_move_line.py b/addons/stock/models/stock_move_line.py
index 5f15e641ad7..f7d874d9bb6 100644
--- a/addons/stock/models/stock_move_line.py
+++ b/addons/stock/models/stock_move_line.py
@@ -353,7 +353,7 @@ class StockMoveLine(models.Model):
moves._recompute_state()
return res
- def _action_done(self):
+ def _action_done(self, cancel_backorder=False):
""" This method is called during a move's `action_done`. It'll actually move a quant from
the source location to the destination location, and unreserve if needed in the source
location.
diff --git a/addons/stock/models/stock_package_level.py b/addons/stock/models/stock_package_level.py
index a4d0c5be010..b6c7fb5a098 100644
--- a/addons/stock/models/stock_package_level.py
+++ b/addons/stock/models/stock_package_level.py
@@ -27,7 +27,7 @@ class StockPackageLevel(models.Model):
],string='State', compute='_compute_state')
is_fresh_package = fields.Boolean(compute='_compute_fresh_pack')
- picking_source_location = fields.Many2one('stock.location', related='picking_id.location_id', readonly=False)
+ picking_type_code = fields.Selection(related='picking_id.picking_type_code')
show_lots_m2o = fields.Boolean(compute='_compute_show_lot')
show_lots_text = fields.Boolean(compute='_compute_show_lot')
@@ -182,3 +182,20 @@ class StockPackageLevel(models.Model):
pl.location_id = pl.move_line_ids[0].location_id
else:
pl.location_id = pl.picking_id.location_id
+
+ def action_show_package_details(self):
+ self.ensure_one()
+ view = self.env.ref('stock.package_level_form_view')
+
+ return {
+ 'name': _('Package Content'),
+ 'type': 'ir.actions.act_window',
+ 'view_type': 'form',
+ 'view_mode': 'form',
+ 'res_model': 'stock.package_level',
+ 'views': [(view.id, 'form')],
+ 'view_id': view.id,
+ 'target': 'new',
+ 'res_id': self.id,
+ 'flags': {'mode': 'readonly'},
+ }
diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py
index fec28f96de1..1157559ead6 100644
--- a/addons/stock/models/stock_picking.py
+++ b/addons/stock/models/stock_picking.py
@@ -239,7 +239,7 @@ class Picking(models.Model):
readonly=True, required=True,
states={'draft': [('readonly', False)]})
move_lines = fields.One2many('stock.move', 'picking_id', string="Stock Moves", copy=True)
- move_ids_without_package = fields.One2many('stock.move', 'picking_id', string="Stock moves not in package", domain=['|',('package_level_id', '=', False), ('picking_type_entire_packs', '=', False)])
+ move_ids_without_package = fields.One2many('stock.move', 'picking_id', string="Stock moves not in package", compute='_compute_move_without_package', inverse='_set_move_without_package')
has_scrap_move = fields.Boolean(
'Has Scrap Moves', compute='_has_scrap_move')
picking_type_id = fields.Many2one(
@@ -622,10 +622,29 @@ class Picking(models.Model):
new_move._action_confirm()
todo_moves |= new_move
#'qty_done': ops.qty_done})
- todo_moves._action_done()
+ todo_moves._action_done(cancel_backorder=self.env.context.get('cancel_backorder'))
self.write({'date_done': fields.Datetime.now()})
return True
+ @api.depends('state', 'move_lines', 'move_lines.state', 'move_lines.package_level_id')
+ def _compute_move_without_package(self):
+ for picking in self:
+ move_ids_without_package = self.env['stock.move']
+ if picking.picking_type_entire_packs == False:
+ move_ids_without_package = picking.move_lines
+ else:
+ for move in picking.move_lines:
+ if not move.package_level_id:
+ if move.state in ('assigned', 'done'):
+ if any(not ml.package_level_id for ml in move.move_line_ids):
+ move_ids_without_package |= move
+ else:
+ move_ids_without_package |= move
+ picking.move_ids_without_package = move_ids_without_package
+
+ def _set_move_without_package(self):
+ self.move_lines |= self.move_ids_without_package
+
def _check_move_lines_map_quant_package(self, package):
""" This method checks that all product of the package (quant) are well present in the move_line_ids of the picking. """
all_in = True
@@ -807,10 +826,11 @@ class Picking(models.Model):
precision_rounding=move.product_uom.rounding) == 1
)
- @api.multi
- def _create_backorder(self, backorder_moves=[]):
- """ Move all non-done lines into a new backorder picking.
- """
+ def _create_backorder(self):
+
+ """ This method is called when the user chose to create a backorder. It will create a new
+        picking, the backorder, and move the stock.moves that are not `done` or `cancel` into it.
+        """
backorders = self.env['stock.picking']
for picking in self:
moves_to_backorder = picking.move_lines.filtered(lambda x: x.state not in ('done', 'cancel'))
diff --git a/addons/stock/models/stock_warehouse.py b/addons/stock/models/stock_warehouse.py
index dc270bff609..2e2d171eddf 100644
--- a/addons/stock/models/stock_warehouse.py
+++ b/addons/stock/models/stock_warehouse.py
@@ -121,7 +121,7 @@ class Warehouse(models.Model):
def write(self, vals):
Route = self.env['stock.location.route']
- warehouses = self.with_context(active_test=False) # TDE FIXME: check this
+ warehouses = self.with_context(active_test=False)
if vals.get('code') or vals.get('name'):
warehouses._update_name_and_code(vals.get('name'), vals.get('code'))
@@ -146,7 +146,7 @@ class Warehouse(models.Model):
res = super(Warehouse, self).write(vals)
- for warehouse in self:
+ for warehouse in warehouses:
# check if we need to delete and recreate route
depends = [depend for depends in [value['depends'] for value in warehouse._get_routes_values().values()] for depend in depends]
if any(depend in vals for depend in depends):
@@ -166,12 +166,66 @@ class Warehouse(models.Model):
any(depend in vals for depend in depends):
warehouse._create_or_update_global_routes_rules()
+ if 'active' in vals:
+ picking_type_ids = self.env['stock.picking.type'].with_context(active_test=False).search([('warehouse_id', '=', warehouse.id)])
+ move_ids = self.env['stock.move'].search([
+ ('picking_type_id', 'in', picking_type_ids.ids),
+ ('state', 'not in', ('done', 'cancel')),
+ ])
+ if move_ids:
+ raise UserError(_('You still have ongoing operations for picking\
+ types %s in warehouse %s') %
+ (', '.join(move_ids.mapped('picking_type_id.name')), warehouse.name))
+ else:
+ picking_type_ids.write({'active': vals['active']})
+ location_ids = self.env['stock.location'].with_context(active_test=False).search([('location_id', 'child_of', warehouse.view_location_id.id)])
+ picking_type_using_locations = self.env['stock.picking.type'].search([
+ ('default_location_src_id', 'in', location_ids.ids),
+ ('default_location_dest_id', 'in', location_ids.ids),
+ ('id', 'not in', picking_type_ids.ids),
+ ])
+ if picking_type_using_locations:
+ raise UserError(_('%s use default source or destination locations\
+ from warehouse %s that will be archived.') %
+ (', '.join(picking_type_using_locations.mapped('name')), warehouse.name))
+ warehouse.view_location_id.write({'active': vals['active']})
+
+ rule_ids = self.env['stock.rule'].with_context(active_test=False).search([('warehouse_id', '=', self.id)])
+ # Only modify route that apply on this warehouse.
+ route_ids = warehouse.route_ids.filtered(lambda r: len(r.warehouse_ids) == 1).write({'active': vals['active']})
+ rule_ids.write({'active': vals['active']})
+
+ if warehouse.active:
+ # Catch all warehouse fields that trigger a modfication on
+ # routes, rules, picking types and locations (e.g the reception
+ # steps). The purpose is to write on it in order to let the
+ # write method set the correct field to active or archive.
+ depends = set([])
+ for rule_item in warehouse._get_global_route_rules_values().values():
+ for depend in rule_item.get('depends', []):
+ depends.add(depend)
+ for rule_item in warehouse._get_routes_values().values():
+ for depend in rule_item.get('depends', []):
+ depends.add(depend)
+ values = {'resupply_route_ids': [(4, route.id) for route in warehouse.resupply_route_ids]}
+ for depend in depends:
+ values.update({depend: warehouse[depend]})
+ warehouse.write(values)
+
if vals.get('resupply_wh_ids') and not vals.get('resupply_route_ids'):
for warehouse in warehouses:
to_add = new_resupply_whs - old_resupply_whs[warehouse.id]
to_remove = old_resupply_whs[warehouse.id] - new_resupply_whs
if to_add:
- warehouse.create_resupply_routes(to_add)
+ existing_route = Route.search([
+ ('supplied_wh_id', '=', warehouse.id),
+ ('supplier_wh_id', 'in', to_remove.ids),
+ ('active', '=', False)
+ ])
+ if existing_route:
+ existing_route.write({'active': True})
+ else:
+ warehouse.create_resupply_routes(to_add)
if to_remove:
Route.search([('supplied_wh_id', '=', warehouse.id), ('supplier_wh_id', 'in', to_remove.ids)]).write({'active': False})
# TDE FIXME: shouldn't we remove stock rules also ? because this could make them global (not sure)
@@ -366,7 +420,8 @@ class Warehouse(models.Model):
'routing_key': self.reception_steps,
'depends': ['reception_steps'],
'route_update_values': {
- 'name': self._format_routename(route_type=self.reception_steps)
+ 'name': self._format_routename(route_type=self.reception_steps),
+ 'active': self.active,
},
'route_create_values': {
'product_categ_selectable': True,
@@ -384,7 +439,8 @@ class Warehouse(models.Model):
'routing_key': self.delivery_steps,
'depends': ['delivery_steps'],
'route_update_values': {
- 'name': self._format_routename(route_type=self.delivery_steps)
+ 'name': self._format_routename(route_type=self.delivery_steps),
+ 'active': self.active,
},
'route_create_values': {
'product_categ_selectable': True,
@@ -665,26 +721,12 @@ class Warehouse(models.Model):
warehouse.int_type_id.sequence_id.write(sequence_data['int_type_id'])
def _update_location_reception(self, new_reception_step):
- switch_warehouses = self.filtered(lambda wh: wh.reception_steps != new_reception_step and not wh._location_used(wh.wh_input_stock_loc_id))
- if switch_warehouses:
- (switch_warehouses.mapped('wh_input_stock_loc_id') + switch_warehouses.mapped('wh_qc_stock_loc_id')).write({'active': False})
- if new_reception_step == 'three_steps':
- self.mapped('wh_qc_stock_loc_id').write({'active': True})
- if new_reception_step != 'one_step':
- self.mapped('wh_input_stock_loc_id').write({'active': True})
+ self.mapped('wh_qc_stock_loc_id').write({'active': new_reception_step == 'three_steps'})
+ self.mapped('wh_input_stock_loc_id').write({'active': new_reception_step != 'one_step'})
def _update_location_delivery(self, new_delivery_step):
- switch_warehouses = self.filtered(lambda wh: wh.delivery_steps != new_delivery_step)
- loc_warehouse = switch_warehouses.filtered(lambda wh: not wh._location_used(wh.wh_output_stock_loc_id))
- if loc_warehouse:
- loc_warehouse.mapped('wh_output_stock_loc_id').write({'active': False})
- loc_warehouse = switch_warehouses.filtered(lambda wh: not wh._location_used(wh.wh_pack_stock_loc_id))
- if loc_warehouse:
- loc_warehouse.mapped('wh_pack_stock_loc_id').write({'active': False})
- if new_delivery_step == 'pick_pack_ship':
- self.mapped('wh_pack_stock_loc_id').write({'active': True})
- if new_delivery_step != 'ship_only':
- self.mapped('wh_output_stock_loc_id').write({'active': True})
+ self.mapped('wh_pack_stock_loc_id').write({'active': new_delivery_step == 'pick_pack_ship'})
+ self.mapped('wh_output_stock_loc_id').write({'active': new_delivery_step != 'ship_only'})
def _location_used(self, location):
rules = self.env['stock.rule'].search_count([
diff --git a/addons/stock/tests/test_packing.py b/addons/stock/tests/test_packing.py
index 9c711acd3e7..fcda966d04a 100644
--- a/addons/stock/tests/test_packing.py
+++ b/addons/stock/tests/test_packing.py
@@ -63,7 +63,11 @@ class TestPacking(TransactionCase):
packing_picking = pack_move_a.picking_id
shipping_picking = ship_move_a.picking_id
+ pick_picking.picking_type_entire_packs = True
+ packing_picking.picking_type_entire_packs = True
+ shipping_picking.picking_type_entire_packs = True
pick_picking.action_assign()
+ self.assertEqual(len(pick_picking.move_ids_without_package), 2)
pick_picking.move_line_ids.filtered(lambda ml: ml.product_id == self.productA).qty_done = 1.0
pick_picking.move_line_ids.filtered(lambda ml: ml.product_id == self.productB).qty_done = 2.0
@@ -73,7 +77,10 @@ class TestPacking(TransactionCase):
pick_picking.move_line_ids.filtered(lambda ml: ml.product_id == self.productA and ml.qty_done == 0.0).qty_done = 4.0
pick_picking.move_line_ids.filtered(lambda ml: ml.product_id == self.productB and ml.qty_done == 0.0).qty_done = 3.0
second_pack = pick_picking.put_in_pack()
+ self.assertEqual(len(pick_picking.move_ids_without_package), 0)
+ self.assertEqual(len(packing_picking.move_ids_without_package), 2)
pick_picking.button_validate()
+ self.assertEqual(len(packing_picking.move_ids_without_package), 0)
self.assertEqual(len(first_pack.quant_ids), 2)
self.assertEqual(len(second_pack.quant_ids), 2)
packing_picking.action_assign()
@@ -91,6 +98,7 @@ class TestPacking(TransactionCase):
'location_dest_id': self.stock_location.id,
'state': 'draft',
})
+ picking.picking_type_entire_packs = True
package_level = self.env['stock.package_level'].create({
'package_id': pack.id,
'picking_id': picking.id,
@@ -99,6 +107,7 @@ class TestPacking(TransactionCase):
self.assertEquals(package_level.state, 'draft',
'The package_level should be in draft as it has no moves, move lines and is not confirmed')
picking.action_confirm()
+ self.assertEqual(len(picking.move_ids_without_package), 0)
self.assertEqual(len(picking.move_lines), 1,
'One move should be created when the package_level has been confirmed')
self.assertEquals(len(package_level.move_ids), 1,
diff --git a/addons/stock/tests/test_stock_flow.py b/addons/stock/tests/test_stock_flow.py
index 592d0138515..29afb5aa4c2 100644
--- a/addons/stock/tests/test_stock_flow.py
+++ b/addons/stock/tests/test_stock_flow.py
@@ -1791,3 +1791,55 @@ class TestStockFlow(TestStockCommon):
self.assertEquals(move_mto_alone.state, "draft")
self.assertEquals(move_with_ancestors.state, "waiting")
self.assertEquals(other_move.state, "confirmed")
+
+ def test_80_partial_picking_without_backorder(self):
+ """ This test will create a picking with an initial demand for a product
+ then process a lesser quantity than the expected quantity to be processed.
+ When the wizard ask for a backorder, the 'NO BACKORDER' option will be selected
+ and no backorder should be created afterwards
+ """
+
+ picking = self.PickingObj.create({
+ 'partner_id': self.partner_delta_id,
+ 'picking_type_id': self.picking_type_in,
+ 'location_id': self.supplier_location,
+ 'location_dest_id': self.stock_location})
+ move_a = self.MoveObj.create({
+ 'name': self.productA.name,
+ 'product_id': self.productA.id,
+ 'product_uom_qty': 10,
+ 'product_uom': self.productA.uom_id.id,
+ 'picking_id': picking.id,
+ 'location_id': self.supplier_location,
+ 'location_dest_id': self.stock_location})
+
+ picking.action_confirm()
+
+ # Only 4 items are processed
+ move_a.move_line_ids.qty_done = 4
+ backorder_wizard = self.env['stock.backorder.confirmation'].create({'pick_ids': [(4, picking.id)]})
+ backorder_wizard.process_cancel_backorder()
+
+ # Checking that no backorders were attached to the picking
+ self.assertFalse(picking.backorder_id)
+
+ # Checking that the original move is still in the same picking
+ self.assertEquals(move_a.picking_id.id, picking.id)
+
+ move_lines = picking.move_lines
+ move_done = move_lines.browse(move_a.id)
+ move_canceled = move_lines - move_done
+
+ # Checking that the original move was set to done
+ self.assertEquals(move_done.product_uom_qty, 4)
+ self.assertEquals(move_done.state, 'done')
+
+ # Checking that the new move created was canceled
+ self.assertEquals(move_canceled.product_uom_qty, 6)
+ self.assertEquals(move_canceled.state, 'cancel')
+
+ # Checking that the canceled move is in the original picking
+ self.assertIn(move_canceled.id, picking.move_lines.mapped('id'))
+
+
+
diff --git a/addons/stock/tests/test_warehouse.py b/addons/stock/tests/test_warehouse.py
index 8c5d10cd3f0..9a93da5c939 100644
--- a/addons/stock/tests/test_warehouse.py
+++ b/addons/stock/tests/test_warehouse.py
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
from odoo.addons.stock.tests.common2 import TestStockCommon
-
+from odoo.tests import Form
class TestWarehouse(TestStockCommon):
@@ -34,9 +34,9 @@ class TestWarehouse(TestStockCommon):
self.assertIn(inventory.name, inventory.move_ids.name)
self.assertEqual(inventory.move_ids.product_qty, 15.0)
self.assertEqual(inventory.move_ids.location_id, self.warehouse_1.lot_stock_id)
- self.assertEqual(inventory.move_ids.location_dest_id, self.env.ref('stock.location_inventory')) # Inventory loss
+ self.assertEqual(inventory.move_ids.location_dest_id, self.product_1.property_stock_inventory) # Inventory loss
self.assertEqual(inventory.move_ids.state, 'done')
- quants = self.env['stock.quant']._gather(self.product_1, self.env.ref('stock.location_inventory'))
+ quants = self.env['stock.quant']._gather(self.product_1, self.product_1.property_stock_inventory)
self.assertEqual(len(quants), 1) # One quant created for inventory loss
# Check quantity of product in various locations: current, its parent, brother and other
@@ -176,7 +176,7 @@ class TestWarehouse(TestStockCommon):
productA = self.env['product.product'].create({'name': 'Product A', 'type': 'product'})
stock_location = self.env.ref('stock.stock_location_stock')
customer_location = self.env.ref('stock.stock_location_customers')
- location_loss = self.env.ref('stock.location_inventory')
+ location_loss = productA.property_stock_inventory
# Create a picking out and force availability
picking_out = self.env['stock.picking'].create({
@@ -431,6 +431,148 @@ class TestWarehouse(TestStockCommon):
# Ensure there still no quants in distribution warehouse
self.assertEqual(sum(self.env['stock.quant']._gather(product, warehouse_distribution_wavre.lot_stock_id).mapped('quantity')), 0)
+ def test_toggle_active_warehouse_1(self):
+ """ Basic test that create a warehouse with classic configuration.
+ Archive it and check that locations, picking types, routes, rules are
+ correclty active or archive.
+ """
+ wh = Form(self.env['stock.warehouse'])
+ wh.name = "The attic of Willy"
+ wh.code = "WIL"
+ warehouse = wh.save()
+
+ custom_location = Form(self.env['stock.location'])
+ custom_location.name = "A Trunk"
+ custom_location.location_id = warehouse.lot_stock_id
+ custom_location = custom_location.save()
+
+ # Archive warehouse
+ warehouse.toggle_active()
+ # Global rule
+ self.assertFalse(warehouse.mto_pull_id.active)
+
+ # Route
+ self.assertFalse(warehouse.reception_route_id.active)
+ self.assertFalse(warehouse.delivery_route_id.active)
+
+ # Location
+ self.assertFalse(warehouse.lot_stock_id.active)
+ self.assertFalse(warehouse.wh_input_stock_loc_id.active)
+ self.assertFalse(warehouse.wh_qc_stock_loc_id.active)
+ self.assertFalse(warehouse.wh_output_stock_loc_id.active)
+ self.assertFalse(warehouse.wh_pack_stock_loc_id.active)
+ self.assertFalse(custom_location.active)
+
+ # Picking Type
+ self.assertFalse(warehouse.in_type_id.active)
+ self.assertFalse(warehouse.out_type_id.active)
+ self.assertFalse(warehouse.int_type_id.active)
+ self.assertFalse(warehouse.pick_type_id.active)
+ self.assertFalse(warehouse.pack_type_id.active)
+
+ # Active warehouse
+ warehouse.toggle_active()
+ # Global rule
+ self.assertTrue(warehouse.mto_pull_id.active)
+
+ # Route
+ self.assertTrue(warehouse.reception_route_id.active)
+ self.assertTrue(warehouse.delivery_route_id.active)
+
+ # Location
+ self.assertTrue(warehouse.lot_stock_id.active)
+ self.assertFalse(warehouse.wh_input_stock_loc_id.active)
+ self.assertFalse(warehouse.wh_qc_stock_loc_id.active)
+ self.assertFalse(warehouse.wh_output_stock_loc_id.active)
+ self.assertFalse(warehouse.wh_pack_stock_loc_id.active)
+ self.assertTrue(custom_location.active)
+
+ # Picking Type
+ self.assertTrue(warehouse.in_type_id.active)
+ self.assertTrue(warehouse.out_type_id.active)
+ self.assertTrue(warehouse.int_type_id.active)
+ self.assertFalse(warehouse.pick_type_id.active)
+ self.assertFalse(warehouse.pack_type_id.active)
+
+ def test_toggle_active_warehouse_2(self):
+ wh = Form(self.env['stock.warehouse'])
+ wh.name = "The attic of Willy"
+ wh.code = "WIL"
+ wh.reception_steps = "two_steps"
+ wh.delivery_steps = "pick_pack_ship"
+ warehouse = wh.save()
+
+ warehouse.resupply_wh_ids = [(6, 0, [self.warehouse_1.id])]
+
+ custom_location = Form(self.env['stock.location'])
+ custom_location.name = "A Trunk"
+ custom_location.location_id = warehouse.lot_stock_id
+ custom_location = custom_location.save()
+
+ # Add a warehouse on the route.
+ warehouse.reception_route_id.write({
+ 'warehouse_ids': [(4, self.warehouse_1.id)]
+ })
+
+ route = Form(self.env['stock.location.route'])
+ route.name = "Stair"
+ route = route.save()
+
+ route.warehouse_ids = [(6, 0, [warehouse.id, self.warehouse_1.id])]
+
+ # Pre archive a location and a route
+ warehouse.delivery_route_id.toggle_active()
+ warehouse.wh_pack_stock_loc_id.toggle_active()
+
+ # Archive warehouse
+ warehouse.toggle_active()
+ # Global rule
+ self.assertFalse(warehouse.mto_pull_id.active)
+
+ # Route
+ self.assertTrue(warehouse.reception_route_id.active)
+ self.assertFalse(warehouse.delivery_route_id.active)
+ self.assertTrue(route.active)
+
+ # Location
+ self.assertFalse(warehouse.lot_stock_id.active)
+ self.assertFalse(warehouse.wh_input_stock_loc_id.active)
+ self.assertFalse(warehouse.wh_qc_stock_loc_id.active)
+ self.assertFalse(warehouse.wh_output_stock_loc_id.active)
+ self.assertFalse(warehouse.wh_pack_stock_loc_id.active)
+ self.assertFalse(custom_location.active)
+
+ # Picking Type
+ self.assertFalse(warehouse.in_type_id.active)
+ self.assertFalse(warehouse.out_type_id.active)
+ self.assertFalse(warehouse.int_type_id.active)
+ self.assertFalse(warehouse.pick_type_id.active)
+ self.assertFalse(warehouse.pack_type_id.active)
+
+ # Active warehouse
+ warehouse.toggle_active()
+ # Global rule
+ self.assertTrue(warehouse.mto_pull_id.active)
+
+ # Route
+ self.assertTrue(warehouse.reception_route_id.active)
+ self.assertTrue(warehouse.delivery_route_id.active)
+
+ # Location
+ self.assertTrue(warehouse.lot_stock_id.active)
+ self.assertTrue(warehouse.wh_input_stock_loc_id.active)
+ self.assertFalse(warehouse.wh_qc_stock_loc_id.active)
+ self.assertTrue(warehouse.wh_output_stock_loc_id.active)
+ self.assertTrue(warehouse.wh_pack_stock_loc_id.active)
+ self.assertTrue(custom_location.active)
+
+ # Picking Type
+ self.assertTrue(warehouse.in_type_id.active)
+ self.assertTrue(warehouse.out_type_id.active)
+ self.assertTrue(warehouse.int_type_id.active)
+ self.assertTrue(warehouse.pick_type_id.active)
+ self.assertTrue(warehouse.pack_type_id.active)
+
class TestResupply(TestStockCommon):
def setUp(self):
super(TestResupply, self).setUp()
diff --git a/addons/stock/views/stock_package_level_views.xml b/addons/stock/views/stock_package_level_views.xml
index ed7692b7fc8..30ed9f999a8 100644
--- a/addons/stock/views/stock_package_level_views.xml
+++ b/addons/stock/views/stock_package_level_views.xml
@@ -4,19 +4,19 @@
<field name="name">Package Level</field>
<field name="model">stock.package_level</field>
<field name="arch" type="xml">
- <form>
+ <form create="false" edit="false">
<header>
<field name="state" widget="statusbar" statusbar_visible="draft,confirmed,assigned,done" />
</header>
<group>
<field name="picking_id" invisible="1"/>
- <field name="picking_source_location" invisible="1"/>
<field name="show_lots_m2o" invisible="1"/>
<field name="show_lots_text" invisible="1"/>
+ <field name="picking_type_code" invisible="1"/>
<group>
<field name="package_id"/>
- <field name="location_id" groups="stock.group_stock_multi_locations"/>
- <field name="location_dest_id" groups="stock.group_stock_multi_locations"/>
+ <field name="location_id" attrs="{'invisible': [('picking_type_code', '=', 'incoming')]}" groups="stock.group_stock_multi_locations"/>
+ <field name="location_dest_id" attrs="{'invisible': [('picking_type_code', '=', 'outgoing')]}" groups="stock.group_stock_multi_locations"/>
<field name="is_done"/>
</group>
<field name="move_ids" attrs="{'invisible': [('state', 'in', ('new', 'draft', 'assigned', 'done'))]}">
@@ -57,6 +57,7 @@
<field name="location_dest_id" domain="[('id', 'child_of', parent.location_dest_id)]" attrs="{'column_invisible': [('parent.picking_type_code', '=', 'outgoing')]}" groups="stock.group_stock_multi_locations"/>
<field name="state"/>
<field name="is_done" attrs="{'readonly': ['|', ('parent.state', 'in', ('draft', 'new', 'done')), ('is_fresh_package', '=', True)]}"/>
+ <button name="action_show_package_details" string="Display package content" type="object" icon="fa-list" />
</tree>
</field>
</record>
diff --git a/addons/stock/views/stock_picking_views.xml b/addons/stock/views/stock_picking_views.xml
index b4f47463482..6cfb17a39e7 100644
--- a/addons/stock/views/stock_picking_views.xml
+++ b/addons/stock/views/stock_picking_views.xml
@@ -323,14 +323,12 @@
</page>
<page string="Additional Info" name="extra">
<group>
- <group>
+ <group string="Other Information" name="other_infos">
<field name="picking_type_code" invisible="1"/>
<field name="move_type" attrs="{'invisible': [('picking_type_code', '=', 'incoming')]}"/>
- </group>
- <group>
- <field name="company_id" groups="base.group_multi_company" options="{'no_create': True}"/>
+ <field name="priority"/>
<field name="group_id" groups="base.group_no_one"/>
- <field name="priority" attrs="{'invisible': [('picking_type_code', '=', 'incoming')]}"/>
+ <field name="company_id" groups="base.group_multi_company" options="{'no_create': True}"/>
</group>
</group>
</page>
diff --git a/addons/stock/wizard/stock_backorder_confirmation.py b/addons/stock/wizard/stock_backorder_confirmation.py
index d0c30a2ffc0..8b0b70ccea3 100644
--- a/addons/stock/wizard/stock_backorder_confirmation.py
+++ b/addons/stock/wizard/stock_backorder_confirmation.py
@@ -20,12 +20,7 @@ class StockBackorderConfirmation(models.TransientModel):
if float_compare(move.product_uom_qty, move.quantity_done, move.product_uom.rounding) > 0:
moves_to_log[move] = (move.quantity_done, move.product_uom_qty)
pick_id._log_less_quantities_than_expected(moves_to_log)
- self.pick_ids.action_done()
- if cancel_backorder:
- for pick_id in self.pick_ids:
- backorder_pick = self.env['stock.picking'].search([('backorder_id', '=', pick_id.id)])
- backorder_pick.action_cancel()
- pick_id.message_post(body=_("Back order <em>%s</em> <b>cancelled</b>.") % (backorder_pick.name))
+ self.pick_ids.with_context({'cancel_backorder': cancel_backorder}).action_done()
def process(self):
self._process()
diff --git a/addons/stock_account/models/stock.py b/addons/stock_account/models/stock.py
index f13f8316977..e8a645aeca1 100644
--- a/addons/stock_account/models/stock.py
+++ b/addons/stock_account/models/stock.py
@@ -348,9 +348,9 @@ class StockMove(models.Model):
'price_unit': price_unit if self._is_dropshipped() else -price_unit,
})
- def _action_done(self):
+ def _action_done(self, cancel_backorder=False):
self.product_price_update_before_done()
- res = super(StockMove, self)._action_done()
+ res = super(StockMove, self)._action_done(cancel_backorder=cancel_backorder)
for move in res:
# Apply restrictions on the stock move to be able to make
# consistent accounting entries.
diff --git a/addons/stock_landed_costs/models/stock_landed_cost.py b/addons/stock_landed_costs/models/stock_landed_cost.py
index 07aa7be9a51..4e3c022a4b1 100644
--- a/addons/stock_landed_costs/models/stock_landed_cost.py
+++ b/addons/stock_landed_costs/models/stock_landed_cost.py
@@ -73,7 +73,7 @@ class LandedCost(models.Model):
@api.multi
def _track_subtype(self, init_values):
if 'state' in init_values and self.state == 'done':
- return 'stock_landed_costs.mt_stock_landed_cost_open'
+ return self.env.ref('stock_landed_costs.mt_stock_landed_cost_open')
return super(LandedCost, self)._track_subtype(init_values)
@api.multi
diff --git a/addons/stock_picking_batch/models/stock_picking_batch.py b/addons/stock_picking_batch/models/stock_picking_batch.py
index 18a3e9cb4b2..18592687d6b 100644
--- a/addons/stock_picking_batch/models/stock_picking_batch.py
+++ b/addons/stock_picking_batch/models/stock_picking_batch.py
@@ -106,7 +106,7 @@ class StockPickingBatch(models.Model):
def _track_subtype(self, init_values):
if 'state' in init_values:
- return 'stock_picking_batch.mt_batch_state'
+ return self.env.ref('stock_picking_batch.mt_batch_state')
return super(StockPickingBatch, self)._track_subtype(init_values)
diff --git a/addons/survey/__manifest__.py b/addons/survey/__manifest__.py
index 6304e6957d7..2c2e45bf93d 100644
--- a/addons/survey/__manifest__.py
+++ b/addons/survey/__manifest__.py
@@ -18,19 +18,27 @@ sent mails with personal token for the invitation of the survey.
'website': 'https://www.odoo.com/page/survey',
'depends': ['http_routing', 'mail'],
'data': [
+ 'data/mail_template_data.xml',
+ 'data/survey_data.xml',
+ 'data/ir_actions_data.xml',
'security/survey_security.xml',
'security/ir.model.access.csv',
- 'views/survey_views.xml',
+ 'views/assets.xml',
+ 'views/survey_menus.xml',
+ 'views/survey_stage_views.xml',
+ 'views/survey_survey_views.xml',
+ 'views/survey_user_views.xml',
+ 'views/survey_question_views.xml',
+ 'views/survey_page_views.xml',
'views/survey_templates.xml',
- 'views/survey_result.xml',
- 'data/mail_template_data.xml',
- 'wizard/survey_email_compose_message.xml',
- 'data/survey_stages.xml',
+ 'wizard/survey_mail_compose_message_views.xml',
+ ],
+ 'demo': [
+ 'data/survey_demo_user.xml',
+ 'data/survey_demo_feedback.xml',
+ 'data/survey.user_input.csv',
+ 'data/survey.user_input_line.csv'
],
- 'demo': ['data/survey_demo_user.xml',
- 'data/survey_demo_feedback.xml',
- 'data/survey.user_input.csv',
- 'data/survey.user_input_line.csv'],
'installable': True,
'auto_install': False,
'application': True,
diff --git a/addons/survey/controllers/main.py b/addons/survey/controllers/main.py
index 3674012e091..efc0e889318 100644
--- a/addons/survey/controllers/main.py
+++ b/addons/survey/controllers/main.py
@@ -166,9 +166,9 @@ class Survey(http.Controller):
answer_value = None
if answer.answer_type == 'free_text':
answer_value = answer.value_free_text
- elif answer.answer_type == 'text' and answer.question_id.type == 'textbox':
+ elif answer.answer_type == 'text' and answer.question_id.question_type == 'textbox':
answer_value = answer.value_text
- elif answer.answer_type == 'text' and answer.question_id.type != 'textbox':
+ elif answer.answer_type == 'text' and answer.question_id.question_type != 'textbox':
# here come comment answers for matrices, simple choice and multiple choice
answer_tag = "%s_%s" % (answer_tag, 'comment')
answer_value = answer.value_text
@@ -225,7 +225,7 @@ class Survey(http.Controller):
user_input = request.env['survey.user_input'].sudo().search([('token', '=', post['token'])], limit=1)
except KeyError: # Invalid token
return request.render("survey.403", {'survey': survey})
- user_id = request.env.user.id if user_input.type != 'link' else SUPERUSER_ID
+ user_id = request.env.user.id if user_input.input_type != 'link' else SUPERUSER_ID
for question in questions:
answer_tag = "%s_%s_%s" % (survey.id, page_id, question.id)
@@ -363,13 +363,13 @@ class Survey(http.Controller):
current_filters = current_filters if current_filters else []
Survey = request.env['survey.survey']
result = []
- if question.type == 'multiple_choice':
+ if question.question_type == 'multiple_choice':
result.append({'key': ustr(question.question),
'values': Survey.prepare_result(question, current_filters)['answers']
})
- if question.type == 'simple_choice':
+ if question.question_type == 'simple_choice':
result = Survey.prepare_result(question, current_filters)['answers']
- if question.type == 'matrix':
+ if question.question_type == 'matrix':
data = Survey.prepare_result(question, current_filters)
for answer in data['answers']:
values = []
diff --git a/addons/survey/data/ir_actions_data.xml b/addons/survey/data/ir_actions_data.xml
new file mode 100644
index 00000000000..7e0473fdbeb
--- /dev/null
+++ b/addons/survey/data/ir_actions_data.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <data noupdate="1">
+ <record id="survey_action_server_clean_test_answers" model="ir.actions.server">
+ <field name="name">Survey: Clean test answers</field>
+ <field name="type">ir.actions.server</field>
+ <field name="model_id" ref="model_survey_survey" />
+ <field name="binding_model_id" ref="model_survey_survey" />
+ <field name="state">code</field>
+ <field name="code">
+if records:
+ env['survey.user_input'].search([('survey_id', 'in', records.ids), ('test_entry', '=', 'True')]).unlink()
+ </field>
+ </record>
+</data>
+</odoo> \ No newline at end of file
diff --git a/addons/survey/data/survey_stages.xml b/addons/survey/data/survey_data.xml
index 87cefb946c9..87cefb946c9 100644
--- a/addons/survey/data/survey_stages.xml
+++ b/addons/survey/data/survey_data.xml
diff --git a/addons/survey/data/survey_demo_feedback.xml b/addons/survey/data/survey_demo_feedback.xml
index 3d3285271a3..01e591dbddb 100644
--- a/addons/survey/data/survey_demo_feedback.xml
+++ b/addons/survey/data/survey_demo_feedback.xml
@@ -21,7 +21,7 @@
<field name="page_id" ref="feedback_1" />
<field name="sequence">1</field>
<field name="question">Are you using Odoo on a daily basis?</field>
- <field name="type">simple_choice</field>
+ <field name="question_type">simple_choice</field>
<field name="display_mode">dropdown</field>
<field name="constr_mandatory" eval="True" />
</record>
@@ -49,7 +49,7 @@
<field name="page_id" ref="feedback_1" />
<field name="sequence">2</field>
<field name="question">Which modules are you using/testing?</field>
- <field name="type">multiple_choice</field>
+ <field name="question_type">multiple_choice</field>
<field name="column_nb">4</field>
<field name="constr_mandatory" eval="True" />
</record>
@@ -95,7 +95,7 @@ what have been your difficulties.&lt;/p&gt;</field>
<field name="page_id" ref="feedback_2" />
<field name="sequence">1</field>
<field name="question">What do you think about the documentation available on doc.odoo.com?</field>
- <field name="type">matrix</field>
+ <field name="question_type">matrix</field>
<field name="matrix_subtype">simple</field>
<field name="constr_mandatory" eval="True" />
</record>
@@ -148,7 +148,7 @@ what have been your difficulties.&lt;/p&gt;</field>
<field name="page_id" ref="feedback_2" />
<field name="sequence">2</field>
<field name="question">What do you think about the process views of Odoo, available in the web client ?</field>
- <field name="type">matrix</field>
+ <field name="question_type">matrix</field>
<field name="matrix_subtype">simple</field>
<field name="constr_mandatory" eval="True" />
</record>
@@ -201,13 +201,13 @@ what have been your difficulties.&lt;/p&gt;</field>
<field name="page_id" ref="feedback_2" />
<field name="sequence">3</field>
<field name="question">Do you have suggestions on how to improve the process view ?</field>
- <field name="type">free_text</field>
+ <field name="question_type">free_text</field>
</record>
<record model="survey.question" id="feedback_2_4">
<field name="page_id" ref="feedback_2" />
<field name="sequence">4</field>
<field name="question">What do you think about the structure of the menus?</field>
- <field name="type">multiple_choice</field>
+ <field name="question_type">multiple_choice</field>
<field name="column_nb">4</field>
<field name="constr_mandatory" eval="True" />
</record>
@@ -230,7 +230,7 @@ what have been your difficulties.&lt;/p&gt;</field>
<field name="page_id" ref="feedback_2" />
<field name="sequence">5</field>
<field name="question">What do you think about the groups of users?</field>
- <field name="type">matrix</field>
+ <field name="question_type">matrix</field>
<field name="matrix_subtype">simple</field>
<field name="constr_mandatory" eval="True" />
</record>
@@ -283,7 +283,7 @@ what have been your difficulties.&lt;/p&gt;</field>
<field name="page_id" ref="feedback_2" />
<field name="sequence">6</field>
<field name="question">What do you think about the structure of the menus?</field>
- <field name="type">simple_choice</field>
+ <field name="question_type">simple_choice</field>
<field name="display_mode">columns</field>
<field name="column_nb">4</field>
<field name="constr_mandatory" eval="True" />
@@ -307,7 +307,7 @@ what have been your difficulties.&lt;/p&gt;</field>
<field name="page_id" ref="feedback_2" />
<field name="sequence">7</field>
<field name="question">What do you think about configuration wizards?</field>
- <field name="type">matrix</field>
+ <field name="question_type">matrix</field>
<field name="matrix_subtype">simple</field>
<field name="constr_mandatory" eval="True" />
</record>
@@ -362,7 +362,7 @@ what have been your difficulties.&lt;/p&gt;</field>
<field name="page_id" ref="feedback_3" />
<field name="sequence">1</field>
<field name="question">How do you contribute or plan to contribute to Odoo?</field>
- <field name="type">multiple_choice</field>
+ <field name="question_type">multiple_choice</field>
<field name="column_nb">6</field>
<field name="constr_mandatory" eval="True" />
</record>
@@ -395,13 +395,13 @@ what have been your difficulties.&lt;/p&gt;</field>
<field name="page_id" ref="feedback_3" />
<field name="sequence">2</field>
<field name="question">Do you have a proposition to help people to contribute?</field>
- <field name="type">free_text</field>
+ <field name="question_type">free_text</field>
</record>
<record model="survey.question" id="feedback_3_3">
<field name="page_id" ref="feedback_3" />
<field name="sequence">3</field>
<field name="question">Do you have a proposition to attract new contributors?</field>
- <field name="type">free_text</field>
+ <field name="question_type">free_text</field>
</record>
<!-- Page 4 -->
<record model="survey.page" id="feedback_4">
@@ -414,7 +414,7 @@ what have been your difficulties.&lt;/p&gt;</field>
<field name="page_id" ref="feedback_4" />
<field name="sequence">1</field>
<field name="question">Where do you develop your new features?</field>
- <field name="type">multiple_choice</field>
+ <field name="question_type">multiple_choice</field>
<field name="column_nb">6</field>
</record>
<record model="survey.label" id="choice_4_1_1">
diff --git a/addons/survey/models/__init__.py b/addons/survey/models/__init__.py
index 26edfafe8ad..d6a0e6c499b 100644
--- a/addons/survey/models/__init__.py
+++ b/addons/survey/models/__init__.py
@@ -1,5 +1,9 @@
# -*- encoding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from . import survey
from . import ir_autovacuum
+from . import survey_survey
+from . import survey_page
+from . import survey_question
+from . import survey_stage
+from . import survey_user
diff --git a/addons/survey/models/survey.py b/addons/survey/models/survey.py
deleted file mode 100644
index 95a1cc1fdd1..00000000000
--- a/addons/survey/models/survey.py
+++ /dev/null
@@ -1,1053 +0,0 @@
-# -*- coding: utf-8 -*-
-# Part of Odoo. See LICENSE file for full copyright and licensing details.
-
-import datetime
-import logging
-import re
-import uuid
-from collections import Counter, OrderedDict
-from itertools import product
-from werkzeug import urls
-
-from odoo import api, fields, models, tools, SUPERUSER_ID, _
-from odoo.addons.http_routing.models.ir_http import slug
-from odoo.exceptions import UserError, ValidationError
-email_validator = re.compile(r"[^@]+@[^@]+\.[^@]+")
-_logger = logging.getLogger(__name__)
-
-
-def dict_keys_startswith(dictionary, string):
- """Returns a dictionary containing the elements of <dict> whose keys start with <string>.
- .. note::
- This function uses dictionary comprehensions (Python >= 2.7)
- """
- return {k: v for k, v in dictionary.items() if k.startswith(string)}
-
-
-class SurveyStage(models.Model):
- """Stages for Kanban view of surveys"""
-
- _name = 'survey.stage'
- _description = 'Survey Stage'
- _order = 'sequence,id'
-
- name = fields.Char(required=True, translate=True)
- sequence = fields.Integer(default=1)
- closed = fields.Boolean(help="If closed, people won't be able to answer to surveys in this column.")
- fold = fields.Boolean(string="Folded in kanban view")
-
- _sql_constraints = [
- ('positive_sequence', 'CHECK(sequence >= 0)', 'Sequence number MUST be a natural')
- ]
-
-
-class Survey(models.Model):
- """ Settings for a multi-page/multi-question survey.
- Each survey can have one or more attached pages, and each page can display
- one or more questions.
- """
-
- _name = 'survey.survey'
- _description = 'Survey'
- _rec_name = 'title'
- _inherit = ['mail.thread', 'mail.activity.mixin']
-
- def _default_stage(self):
- return self.env['survey.stage'].search([], limit=1).id
-
- title = fields.Char('Title', required=True, translate=True)
- page_ids = fields.One2many('survey.page', 'survey_id', string='Pages', copy=True)
- stage_id = fields.Many2one('survey.stage', string="Stage", default=_default_stage,
- ondelete="restrict", copy=False, group_expand='_read_group_stage_ids')
- auth_required = fields.Boolean('Login required', help="Users with a public link will be requested to login before taking part to the survey",
- oldname="authenticate")
- users_can_go_back = fields.Boolean('Users can go back', help="If checked, users can go back to previous pages.")
- tot_sent_survey = fields.Integer("Number of sent surveys", compute="_compute_survey_statistic")
- tot_start_survey = fields.Integer("Number of started surveys", compute="_compute_survey_statistic")
- tot_comp_survey = fields.Integer("Number of completed surveys", compute="_compute_survey_statistic")
- description = fields.Html("Description", translate=True, help="A long description of the purpose of the survey")
- color = fields.Integer('Color Index', default=0)
- user_input_ids = fields.One2many('survey.user_input', 'survey_id', string='User responses', readonly=True)
- designed = fields.Boolean("Is designed?", compute="_is_designed")
- public_url = fields.Char("Public link", compute="_compute_survey_url")
- public_url_html = fields.Char("Public link (html version)", compute="_compute_survey_url")
- print_url = fields.Char("Print link", compute="_compute_survey_url")
- result_url = fields.Char("Results link", compute="_compute_survey_url")
- email_template_id = fields.Many2one('mail.template', string='Email Template', ondelete='set null')
- thank_you_message = fields.Html("Thanks Message", translate=True, help="This message will be displayed when survey is completed")
- quizz_mode = fields.Boolean("Quizz Mode")
- active = fields.Boolean("Active", default=True)
- is_closed = fields.Boolean("Is closed", related='stage_id.closed', readonly=False)
-
- def _is_designed(self):
- for survey in self:
- if not survey.page_ids or not [page.question_ids for page in survey.page_ids if page.question_ids]:
- survey.designed = False
- else:
- survey.designed = True
-
- @api.multi
- def _compute_survey_statistic(self):
- UserInput = self.env['survey.user_input']
-
- sent_survey = UserInput.search([('survey_id', 'in', self.ids), ('type', '=', 'link')])
- start_survey = UserInput.search(['&', ('survey_id', 'in', self.ids), '|', ('state', '=', 'skip'), ('state', '=', 'done')])
- complete_survey = UserInput.search([('survey_id', 'in', self.ids), ('state', '=', 'done')])
-
- for survey in self:
- survey.tot_sent_survey = len(sent_survey.filtered(lambda user_input: user_input.survey_id == survey))
- survey.tot_start_survey = len(start_survey.filtered(lambda user_input: user_input.survey_id == survey))
- survey.tot_comp_survey = len(complete_survey.filtered(lambda user_input: user_input.survey_id == survey))
-
- def _compute_survey_url(self):
- """ Computes a public URL for the survey """
- base_url = '/' if self.env.context.get('relative_url') else \
- self.env['ir.config_parameter'].sudo().get_param('web.base.url')
- for survey in self:
- survey.public_url = urls.url_join(base_url, "survey/start/%s" % (slug(survey)))
- survey.print_url = urls.url_join(base_url, "survey/print/%s" % (slug(survey)))
- survey.result_url = urls.url_join(base_url, "survey/results/%s" % (slug(survey)))
- survey.public_url_html = '<a href="%s">%s</a>' % (survey.public_url, _("Click here to start survey"))
-
- @api.model
- def _read_group_stage_ids(self, stages, domain, order):
- """ Read group customization in order to display all the stages in the
- kanban view, even if they are empty
- """
- stage_ids = stages._search([], order=order, access_rights_uid=SUPERUSER_ID)
- return stages.browse(stage_ids)
-
- # Public methods #
- def copy_data(self, default=None):
- title = _("%s (copy)") % (self.title)
- default = dict(default or {}, title=title)
- return super(Survey, self).copy_data(default)
-
- @api.model
- def next_page(self, user_input, page_id, go_back=False):
- """ The next page to display to the user, knowing that page_id is the id
- of the last displayed page.
-
- If page_id == 0, it will always return the first page of the survey.
-
- If all the pages have been displayed and go_back == False, it will
- return None
-
- If go_back == True, it will return the *previous* page instead of the
- next page.
-
- .. note::
- It is assumed here that a careful user will not try to set go_back
- to True if she knows that the page to display is the first one!
- (doing this will probably cause a giant worm to eat her house)
- """
- survey = user_input.survey_id
- pages = list(enumerate(survey.page_ids))
-
- # First page
- if page_id == 0:
- return (pages[0][1], 0, len(pages) == 1)
-
- current_page_index = pages.index(next(p for p in pages if p[1].id == page_id))
-
- # All the pages have been displayed
- if current_page_index == len(pages) - 1 and not go_back:
- return (None, -1, False)
- # Let's get back, baby!
- elif go_back and survey.users_can_go_back:
- return (pages[current_page_index - 1][1], current_page_index - 1, False)
- else:
- # This will show the last page
- if current_page_index == len(pages) - 2:
- return (pages[current_page_index + 1][1], current_page_index + 1, True)
- # This will show a regular page
- else:
- return (pages[current_page_index + 1][1], current_page_index + 1, False)
-
- @api.multi
- def filter_input_ids(self, filters, finished=False):
- """If user applies any filters, then this function returns list of
- filtered user_input_id and label's strings for display data in web.
- :param filters: list of dictionary (having: row_id, ansewr_id)
- :param finished: True for completely filled survey,Falser otherwise.
- :returns list of filtered user_input_ids.
- """
- self.ensure_one()
- if filters:
- domain_filter, choice = [], []
- for current_filter in filters:
- row_id, answer_id = current_filter['row_id'], current_filter['answer_id']
- if row_id == 0:
- choice.append(answer_id)
- else:
- domain_filter.extend(['|', ('value_suggested_row.id', '=', row_id), ('value_suggested.id', '=', answer_id)])
- if choice:
- domain_filter.insert(0, ('value_suggested.id', 'in', choice))
- else:
- domain_filter = domain_filter[1:]
- input_lines = self.env['survey.user_input_line'].search(domain_filter)
- filtered_input_ids = [input_line.user_input_id.id for input_line in input_lines]
- else:
- filtered_input_ids = []
- if finished:
- UserInput = self.env['survey.user_input']
- if not filtered_input_ids:
- user_inputs = UserInput.search([('survey_id', '=', self.id)])
- else:
- user_inputs = UserInput.browse(filtered_input_ids)
- return user_inputs.filtered(lambda input_item: input_item.state == 'done').ids
- return filtered_input_ids
-
- @api.model
- def get_filter_display_data(self, filters):
- """Returns data to display current filters
- :param filters: list of dictionary (having: row_id, answer_id)
- :returns list of dict having data to display filters.
- """
- filter_display_data = []
- if filters:
- Label = self.env['survey.label']
- for current_filter in filters:
- row_id, answer_id = current_filter['row_id'], current_filter['answer_id']
- label = Label.browse(answer_id)
- question = label.question_id
- if row_id == 0:
- labels = label
- else:
- labels = Label.browse([row_id, answer_id])
- filter_display_data.append({'question_text': question.question,
- 'labels': labels.mapped('value')})
- return filter_display_data
-
- @api.model
- def prepare_result(self, question, current_filters=None):
- """ Compute statistical data for questions by counting number of vote per choice on basis of filter """
- current_filters = current_filters if current_filters else []
- result_summary = {}
-
- # Calculate and return statistics for choice
- if question.type in ['simple_choice', 'multiple_choice']:
- comments = []
- answers = OrderedDict((label.id, {'text': label.value, 'count': 0, 'answer_id': label.id}) for label in question.labels_ids)
- for input_line in question.user_input_line_ids:
- if input_line.answer_type == 'suggestion' and answers.get(input_line.value_suggested.id) and (not(current_filters) or input_line.user_input_id.id in current_filters):
- answers[input_line.value_suggested.id]['count'] += 1
- if input_line.answer_type == 'text' and (not(current_filters) or input_line.user_input_id.id in current_filters):
- comments.append(input_line)
- result_summary = {'answers': list(answers.values()), 'comments': comments}
-
- # Calculate and return statistics for matrix
- if question.type == 'matrix':
- rows = OrderedDict()
- answers = OrderedDict()
- res = dict()
- comments = []
- [rows.update({label.id: label.value}) for label in question.labels_ids_2]
- [answers.update({label.id: label.value}) for label in question.labels_ids]
- for cell in product(rows, answers):
- res[cell] = 0
- for input_line in question.user_input_line_ids:
- if input_line.answer_type == 'suggestion' and (not(current_filters) or input_line.user_input_id.id in current_filters) and input_line.value_suggested_row:
- res[(input_line.value_suggested_row.id, input_line.value_suggested.id)] += 1
- if input_line.answer_type == 'text' and (not(current_filters) or input_line.user_input_id.id in current_filters):
- comments.append(input_line)
- result_summary = {'answers': answers, 'rows': rows, 'result': res, 'comments': comments}
-
- # Calculate and return statistics for free_text, textbox, date
- if question.type in ['free_text', 'textbox', 'date']:
- result_summary = []
- for input_line in question.user_input_line_ids:
- if not(current_filters) or input_line.user_input_id.id in current_filters:
- result_summary.append(input_line)
-
- # Calculate and return statistics for numerical_box
- if question.type == 'numerical_box':
- result_summary = {'input_lines': []}
- all_inputs = []
- for input_line in question.user_input_line_ids:
- if not(current_filters) or input_line.user_input_id.id in current_filters:
- all_inputs.append(input_line.value_number)
- result_summary['input_lines'].append(input_line)
- if all_inputs:
- result_summary.update({'average': round(sum(all_inputs) / len(all_inputs), 2),
- 'max': round(max(all_inputs), 2),
- 'min': round(min(all_inputs), 2),
- 'sum': sum(all_inputs),
- 'most_common': Counter(all_inputs).most_common(5)})
- return result_summary
-
- @api.model
- def get_input_summary(self, question, current_filters=None):
- """ Returns overall summary of question e.g. answered, skipped, total_inputs on basis of filter """
- current_filters = current_filters if current_filters else []
- result = {}
- if question.survey_id.user_input_ids:
- total_input_ids = current_filters or [input_id.id for input_id in question.survey_id.user_input_ids if input_id.state != 'new']
- result['total_inputs'] = len(total_input_ids)
- question_input_ids = []
- for user_input in question.user_input_line_ids:
- if not user_input.skipped:
- question_input_ids.append(user_input.user_input_id.id)
- result['answered'] = len(set(question_input_ids) & set(total_input_ids))
- result['skipped'] = result['total_inputs'] - result['answered']
- return result
-
- # Actions
-
- @api.multi
- def action_start_survey(self):
- """ Open the website page with the survey form """
- self.ensure_one()
- token = self.env.context.get('survey_token')
- trail = "/%s" % token if token else ""
- return {
- 'type': 'ir.actions.act_url',
- 'name': "Start Survey",
- 'target': 'self',
- 'url': self.with_context(relative_url=True).public_url + trail
- }
-
- @api.multi
- def action_send_survey(self):
- """ Open a window to compose an email, pre-filled with the survey message """
- # Ensure that this survey has at least one page with at least one question.
- if not self.page_ids or not [page.question_ids for page in self.page_ids if page.question_ids]:
- raise UserError(_('You cannot send an invitation for a survey that has no questions.'))
-
- if self.stage_id.closed:
- raise UserError(_("You cannot send invitations for closed surveys."))
-
- template = self.env.ref('survey.email_template_survey', raise_if_not_found=False)
-
- local_context = dict(
- self.env.context,
- default_model='survey.survey',
- default_res_id=self.id,
- default_survey_id=self.id,
- default_use_template=bool(template),
- default_template_id=template and template.id or False,
- default_composition_mode='comment',
- notif_layout='mail.mail_notification_light',
- )
- return {
- 'type': 'ir.actions.act_window',
- 'view_type': 'form',
- 'view_mode': 'form',
- 'res_model': 'survey.mail.compose.message',
- 'target': 'new',
- 'context': local_context,
- }
-
- @api.multi
- def action_print_survey(self):
- """ Open the website page with the survey printable view """
- self.ensure_one()
- token = self.env.context.get('survey_token')
- trail = "/" + token if token else ""
- return {
- 'type': 'ir.actions.act_url',
- 'name': "Print Survey",
- 'target': 'self',
- 'url': self.with_context(relative_url=True).print_url + trail
- }
-
- @api.multi
- def action_result_survey(self):
- """ Open the website page with the survey results view """
- self.ensure_one()
- return {
- 'type': 'ir.actions.act_url',
- 'name': "Results of the Survey",
- 'target': 'self',
- 'url': self.with_context(relative_url=True).result_url
- }
-
- @api.multi
- def action_test_survey(self):
- ''' Open the website page with the survey form into test mode'''
- self.ensure_one()
- return {
- 'type': 'ir.actions.act_url',
- 'name': "Results of the Survey",
- 'target': 'self',
- 'url': self.with_context(relative_url=True).public_url + "/phantom"
- }
-
- @api.multi
- def action_survey_user_input(self):
- action_rec = self.env.ref('survey.action_survey_user_input')
- action = action_rec.read()[0]
- ctx = dict(self.env.context)
- ctx.update({'search_default_survey_id': self.ids[0],
- 'search_default_completed': 1})
- action['context'] = ctx
- return action
-
-
-class SurveyPage(models.Model):
- """ A page for a survey.
-
- Pages are essentially containers, allowing to group questions by ordered
- screens.
-
- .. note::
- A page should be deleted if the survey it belongs to is deleted.
- """
- _name = 'survey.page'
- _description = 'Survey Page'
- _rec_name = 'title'
- _order = 'sequence,id'
-
- # Model Fields #
-
- title = fields.Char('Page Title', required=True, translate=True)
- survey_id = fields.Many2one('survey.survey', string='Survey', ondelete='cascade', required=True)
- question_ids = fields.One2many('survey.question', 'page_id', string='Questions', copy=True)
- sequence = fields.Integer('Page number', default=10)
- description = fields.Html('Description', translate=True, oldname="note", help="An introductory text to your page")
-
-
-class SurveyQuestion(models.Model):
- """ Questions that will be asked in a survey.
-
- Each question can have one of more suggested answers (eg. in case of
- dropdown choices, multi-answer checkboxes, radio buttons...).
- """
-
- _name = 'survey.question'
- _description = 'Survey Question'
- _rec_name = 'question'
- _order = 'sequence,id'
-
- # Model fields #
-
- # Question metadata
- page_id = fields.Many2one('survey.page', string='Survey page',
- ondelete='cascade', required=True, default=lambda self: self.env.context.get('page_id'))
- survey_id = fields.Many2one('survey.survey', related='page_id.survey_id', string='Survey', readonly=False)
- sequence = fields.Integer('Sequence', default=10)
-
- # Question
- question = fields.Char('Question Name', required=True, translate=True)
- description = fields.Html('Description', help="Use this field to add \
- additional explanations about your question", translate=True,
- oldname='descriptive_text')
-
- # Answer
- type = fields.Selection([
- ('free_text', 'Multiple Lines Text Box'),
- ('textbox', 'Single Line Text Box'),
- ('numerical_box', 'Numerical Value'),
- ('date', 'Date'),
- ('simple_choice', 'Multiple choice: only one answer'),
- ('multiple_choice', 'Multiple choice: multiple answers allowed'),
- ('matrix', 'Matrix')], string='Type of Question', default='free_text', required=True)
- matrix_subtype = fields.Selection([('simple', 'One choice per row'),
- ('multiple', 'Multiple choices per row')], string='Matrix Type', default='simple')
- labels_ids = fields.One2many('survey.label', 'question_id', string='Types of answers', oldname='answer_choice_ids', copy=True)
- labels_ids_2 = fields.One2many('survey.label', 'question_id_2', string='Rows of the Matrix', copy=True)
- # labels are used for proposed choices
- # if question.type == simple choice | multiple choice
- # -> only labels_ids is used
- # if question.type == matrix
- # -> labels_ids are the columns of the matrix
- # -> labels_ids_2 are the rows of the matrix
-
- # Display options
- column_nb = fields.Selection([('12', '1'),
- ('6', '2'),
- ('4', '3'),
- ('3', '4'),
- ('2', '6')],
- 'Number of columns', default='12')
- # These options refer to col-xx-[12|6|4|3|2] classes in Bootstrap
- display_mode = fields.Selection([('columns', 'Radio Buttons'),
- ('dropdown', 'Selection Box')],
- default='columns')
-
- # Comments
- comments_allowed = fields.Boolean('Show Comments Field',
- oldname="allow_comment")
- comments_message = fields.Char('Comment Message', translate=True, default=lambda self: _("If other, please specify:"))
- comment_count_as_answer = fields.Boolean('Comment Field is an Answer Choice',
- oldname='make_comment_field')
-
- # Validation
- validation_required = fields.Boolean('Validate entry', oldname='is_validation_require')
- validation_email = fields.Boolean('Input must be an email')
- validation_length_min = fields.Integer('Minimum Text Length')
- validation_length_max = fields.Integer('Maximum Text Length')
- validation_min_float_value = fields.Float('Minimum value')
- validation_max_float_value = fields.Float('Maximum value')
- validation_min_date = fields.Date('Minimum Date')
- validation_max_date = fields.Date('Maximum Date')
- validation_error_msg = fields.Char('Validation Error message', oldname='validation_valid_err_msg',
- translate=True, default=lambda self: _("The answer you entered has an invalid format."))
-
- # Constraints on number of answers (matrices)
- constr_mandatory = fields.Boolean('Mandatory Answer', oldname="is_require_answer")
- constr_error_msg = fields.Char('Error message', oldname='req_error_msg', translate=True, default=lambda self: _("This question requires an answer."))
- user_input_line_ids = fields.One2many('survey.user_input_line', 'question_id', string='Answers', domain=[('skipped', '=', False)])
-
- _sql_constraints = [
- ('positive_len_min', 'CHECK (validation_length_min >= 0)', 'A length must be positive!'),
- ('positive_len_max', 'CHECK (validation_length_max >= 0)', 'A length must be positive!'),
- ('validation_length', 'CHECK (validation_length_min <= validation_length_max)', 'Max length cannot be smaller than min length!'),
- ('validation_float', 'CHECK (validation_min_float_value <= validation_max_float_value)', 'Max value cannot be smaller than min value!'),
- ('validation_date', 'CHECK (validation_min_date <= validation_max_date)', 'Max date cannot be smaller than min date!')
- ]
-
- @api.onchange('validation_email')
- def onchange_validation_email(self):
- if self.validation_email:
- self.validation_required = False
-
- # Validation methods
-
- @api.multi
- def validate_question(self, post, answer_tag):
- """ Validate question, depending on question type and parameters """
- self.ensure_one()
- try:
- checker = getattr(self, 'validate_' + self.type)
- except AttributeError:
- _logger.warning(self.type + ": This type of question has no validation method")
- return {}
- else:
- return checker(post, answer_tag)
-
- @api.multi
- def validate_free_text(self, post, answer_tag):
- self.ensure_one()
- errors = {}
- answer = post[answer_tag].strip()
- # Empty answer to mandatory question
- if self.constr_mandatory and not answer:
- errors.update({answer_tag: self.constr_error_msg})
- return errors
-
- @api.multi
- def validate_textbox(self, post, answer_tag):
- self.ensure_one()
- errors = {}
- answer = post[answer_tag].strip()
- # Empty answer to mandatory question
- if self.constr_mandatory and not answer:
- errors.update({answer_tag: self.constr_error_msg})
- # Email format validation
- # Note: this validation is very basic:
- # all the strings of the form
- # <something>@<anything>.<extension>
- # will be accepted
- if answer and self.validation_email:
- if not email_validator.match(answer):
- errors.update({answer_tag: _('This answer must be an email address')})
- # Answer validation (if properly defined)
- # Length of the answer must be in a range
- if answer and self.validation_required:
- if not (self.validation_length_min <= len(answer) <= self.validation_length_max):
- errors.update({answer_tag: self.validation_error_msg})
- return errors
-
- @api.multi
- def validate_numerical_box(self, post, answer_tag):
- self.ensure_one()
- errors = {}
- answer = post[answer_tag].strip()
- # Empty answer to mandatory question
- if self.constr_mandatory and not answer:
- errors.update({answer_tag: self.constr_error_msg})
- # Checks if user input is a number
- if answer:
- try:
- floatanswer = float(answer)
- except ValueError:
- errors.update({answer_tag: _('This is not a number')})
- # Answer validation (if properly defined)
- if answer and self.validation_required:
- # Answer is not in the right range
- with tools.ignore(Exception):
- floatanswer = float(answer) # check that it is a float has been done hereunder
- if not (self.validation_min_float_value <= floatanswer <= self.validation_max_float_value):
- errors.update({answer_tag: self.validation_error_msg})
- return errors
-
- @api.multi
- def validate_date(self, post, answer_tag):
- self.ensure_one()
- errors = {}
- answer = post[answer_tag].strip()
- # Empty answer to mandatory question
- if self.constr_mandatory and not answer:
- errors.update({answer_tag: self.constr_error_msg})
- # Checks if user input is a date
- if answer:
- try:
- dateanswer = fields.Date.from_string(answer)
- except ValueError:
- errors.update({answer_tag: _('This is not a date')})
- return errors
- # Answer validation (if properly defined)
- if answer and self.validation_required:
- # Answer is not in the right range
- try:
- date_from_string = fields.Date.from_string
- dateanswer = date_from_string(answer)
- min_date = date_from_string(self.validation_min_date)
- max_date = date_from_string(self.validation_max_date)
-
- if min_date and max_date and not (min_date <= dateanswer <= max_date):
- # If Minimum and Maximum Date are entered
- errors.update({answer_tag: self.validation_error_msg})
- elif min_date and not min_date <= dateanswer:
- # If only Minimum Date is entered and not Define Maximum Date
- errors.update({answer_tag: self.validation_error_msg})
- elif max_date and not dateanswer <= max_date:
- # If only Maximum Date is entered and not Define Minimum Date
- errors.update({answer_tag: self.validation_error_msg})
- except ValueError: # check that it is a date has been done hereunder
- pass
- return errors
-
- @api.multi
- def validate_simple_choice(self, post, answer_tag):
- self.ensure_one()
- errors = {}
- if self.comments_allowed:
- comment_tag = "%s_%s" % (answer_tag, 'comment')
- # Empty answer to mandatory self
- if self.constr_mandatory and answer_tag not in post:
- errors.update({answer_tag: self.constr_error_msg})
- if self.constr_mandatory and answer_tag in post and not post[answer_tag].strip():
- errors.update({answer_tag: self.constr_error_msg})
- # Answer is a comment and is empty
- if self.constr_mandatory and answer_tag in post and post[answer_tag] == "-1" and self.comment_count_as_answer and comment_tag in post and not post[comment_tag].strip():
- errors.update({answer_tag: self.constr_error_msg})
- return errors
-
- @api.multi
- def validate_multiple_choice(self, post, answer_tag):
- self.ensure_one()
- errors = {}
- if self.constr_mandatory:
- answer_candidates = dict_keys_startswith(post, answer_tag)
- comment_flag = answer_candidates.pop(("%s_%s" % (answer_tag, -1)), None)
- if self.comments_allowed:
- comment_answer = answer_candidates.pop(("%s_%s" % (answer_tag, 'comment')), '').strip()
- # Preventing answers with blank value
- if all(not answer.strip() for answer in answer_candidates.values()) and answer_candidates:
- errors.update({answer_tag: self.constr_error_msg})
- # There is no answer neither comments (if comments count as answer)
- if not answer_candidates and self.comment_count_as_answer and (not comment_flag or not comment_answer):
- errors.update({answer_tag: self.constr_error_msg})
- # There is no answer at all
- if not answer_candidates and not self.comment_count_as_answer:
- errors.update({answer_tag: self.constr_error_msg})
- return errors
-
- @api.multi
- def validate_matrix(self, post, answer_tag):
- self.ensure_one()
- errors = {}
- if self.constr_mandatory:
- lines_number = len(self.labels_ids_2)
- answer_candidates = dict_keys_startswith(post, answer_tag)
- answer_candidates.pop(("%s_%s" % (answer_tag, 'comment')), '').strip()
- # Number of lines that have been answered
- if self.matrix_subtype == 'simple':
- answer_number = len(answer_candidates)
- elif self.matrix_subtype == 'multiple':
- answer_number = len({sk.rsplit('_', 1)[0] for sk in answer_candidates})
- else:
- raise RuntimeError("Invalid matrix subtype")
- # Validate that each line has been answered
- if answer_number != lines_number:
- errors.update({answer_tag: self.constr_error_msg})
- return errors
-
-
-class SurveyLabel(models.Model):
- """ A suggested answer for a question """
-
- _name = 'survey.label'
- _rec_name = 'value'
- _order = 'sequence,id'
- _description = 'Survey Label'
-
- question_id = fields.Many2one('survey.question', string='Question', ondelete='cascade')
- question_id_2 = fields.Many2one('survey.question', string='Question 2', ondelete='cascade')
- sequence = fields.Integer('Label Sequence order', default=10)
- value = fields.Char('Suggested value', translate=True, required=True)
- quizz_mark = fields.Float('Score for this choice', help="A positive score indicates a correct choice; a negative or null score indicates a wrong answer")
-
- @api.one
- @api.constrains('question_id', 'question_id_2')
- def _check_question_not_empty(self):
- """Ensure that field question_id XOR field question_id_2 is not null"""
- if not bool(self.question_id) != bool(self.question_id_2):
- raise ValidationError(_("A label must be attached to only one question."))
-
-
-class SurveyUserInput(models.Model):
- """ Metadata for a set of one user's answers to a particular survey """
-
- _name = "survey.user_input"
- _rec_name = 'date_create'
- _description = 'Survey User Input'
-
- survey_id = fields.Many2one('survey.survey', string='Survey', required=True, readonly=True, ondelete='restrict')
- date_create = fields.Datetime('Creation Date', default=fields.Datetime.now, required=True, readonly=True, copy=False)
- deadline = fields.Datetime('Deadline', help="Date by which the person can open the survey and submit answers", oldname="date_deadline")
- type = fields.Selection([('manually', 'Manually'), ('link', 'Link')], string='Answer Type', default='manually', required=True, readonly=True, oldname="response_type")
- state = fields.Selection([
- ('new', 'Not started yet'),
- ('skip', 'Partially completed'),
- ('done', 'Completed')], string='Status', default='new', readonly=True)
- test_entry = fields.Boolean(readonly=True)
- token = fields.Char('Identification token', default=lambda self: str(uuid.uuid4()), readonly=True, required=True, copy=False)
-
- # Optional Identification data
- partner_id = fields.Many2one('res.partner', string='Partner', readonly=True)
- email = fields.Char('E-mail', readonly=True)
-
- # Displaying data
- last_displayed_page_id = fields.Many2one('survey.page', string='Last displayed page')
- # The answers !
- user_input_line_ids = fields.One2many('survey.user_input_line', 'user_input_id', string='Answers', copy=True)
-
- # URLs used to display the answers
- result_url = fields.Char("Public link to the survey results", related='survey_id.result_url', readonly=False)
- print_url = fields.Char("Public link to the empty survey", related='survey_id.print_url', readonly=False)
-
- quizz_score = fields.Float("Score for the quiz", compute="_compute_quizz_score", default=0.0)
-
- @api.depends('user_input_line_ids.quizz_mark')
- def _compute_quizz_score(self):
- for user_input in self:
- user_input.quizz_score = sum(user_input.user_input_line_ids.mapped('quizz_mark'))
-
- _sql_constraints = [
- ('unique_token', 'UNIQUE (token)', 'A token must be unique!'),
- ]
-
- @api.model
- def do_clean_emptys(self):
- """ Remove empty user inputs that have been created manually
- (used as a cronjob declared in data/survey_cron.xml)
- """
- an_hour_ago = fields.Datetime.to_string(datetime.datetime.now() - datetime.timedelta(hours=1))
- self.search([('type', '=', 'manually'), ('state', '=', 'new'),
- ('date_create', '<', an_hour_ago)]).unlink()
-
- @api.multi
- def action_survey_resend(self):
- """ Send again the invitation """
- self.ensure_one()
- local_context = {
- 'survey_resent_token': True,
- 'default_partner_ids': self.partner_id and [self.partner_id.id] or [],
- 'default_multi_email': self.email or "",
- 'default_public': 'email_private',
- }
- return self.survey_id.with_context(local_context).action_send_survey()
-
- @api.multi
- def action_view_answers(self):
- """ Open the website page with the survey form """
- self.ensure_one()
- return {
- 'type': 'ir.actions.act_url',
- 'name': "View Answers",
- 'target': 'self',
- 'url': '%s/%s' % (self.print_url, self.token)
- }
-
- @api.multi
- def action_survey_results(self):
- """ Open the website page with the survey results """
- self.ensure_one()
- return {
- 'type': 'ir.actions.act_url',
- 'name': "Survey Results",
- 'target': 'self',
- 'url': self.result_url
- }
-
-
-class SurveyUserInputLine(models.Model):
- _name = 'survey.user_input_line'
- _description = 'Survey User Input Line'
- _rec_name = 'date_create'
-
- user_input_id = fields.Many2one('survey.user_input', string='User Input', ondelete='cascade', required=True)
- question_id = fields.Many2one('survey.question', string='Question', ondelete='restrict', required=True)
- page_id = fields.Many2one(related='question_id.page_id', string="Page", readonly=False)
- survey_id = fields.Many2one(related='user_input_id.survey_id', string='Survey', store=True, readonly=False)
- date_create = fields.Datetime('Create Date', default=fields.Datetime.now, required=True)
- skipped = fields.Boolean('Skipped')
- answer_type = fields.Selection([
- ('text', 'Text'),
- ('number', 'Number'),
- ('date', 'Date'),
- ('free_text', 'Free Text'),
- ('suggestion', 'Suggestion')], string='Answer Type')
- value_text = fields.Char('Text answer')
- value_number = fields.Float('Numerical answer')
- value_date = fields.Date('Date answer')
- value_free_text = fields.Text('Free Text answer')
- value_suggested = fields.Many2one('survey.label', string="Suggested answer")
- value_suggested_row = fields.Many2one('survey.label', string="Row answer")
- quizz_mark = fields.Float('Score given for this choice')
-
- @api.constrains('skipped', 'answer_type')
- def _answered_or_skipped(self):
- for uil in self:
- if not uil.skipped != bool(uil.answer_type):
- raise ValidationError(_('This question cannot be unanswered or skipped.'))
-
- @api.constrains('answer_type')
- def _check_answer_type(self):
- for uil in self:
- fields_type = {
- 'text': bool(uil.value_text),
- 'number': (bool(uil.value_number) or uil.value_number == 0),
- 'date': bool(uil.value_date),
- 'free_text': bool(uil.value_free_text),
- 'suggestion': bool(uil.value_suggested)
- }
- if not fields_type.get(uil.answer_type, True):
- raise ValidationError(_('The answer must be in the right type'))
-
- def _get_mark(self, value_suggested):
- label = self.env['survey.label'].browse(int(value_suggested))
- mark = label.quizz_mark if label.exists() else 0.0
- return mark
-
- @api.model_create_multi
- def create(self, vals_list):
- for vals in vals_list:
- value_suggested = vals.get('value_suggested')
- if value_suggested:
- vals.update({'quizz_mark': self._get_mark(value_suggested)})
- return super(SurveyUserInputLine, self).create(vals_list)
-
- @api.multi
- def write(self, vals):
- value_suggested = vals.get('value_suggested')
- if value_suggested:
- vals.update({'quizz_mark': self._get_mark(value_suggested)})
- return super(SurveyUserInputLine, self).write(vals)
-
- @api.model
- def save_lines(self, user_input_id, question, post, answer_tag):
- """ Save answers to questions, depending on question type
-
- If an answer already exists for question and user_input_id, it will be
- overwritten (in order to maintain data consistency).
- """
- try:
- saver = getattr(self, 'save_line_' + question.type)
- except AttributeError:
- _logger.error(question.type + ": This type of question has no saving function")
- return False
- else:
- saver(user_input_id, question, post, answer_tag)
-
- @api.model
- def save_line_free_text(self, user_input_id, question, post, answer_tag):
- vals = {
- 'user_input_id': user_input_id,
- 'question_id': question.id,
- 'survey_id': question.survey_id.id,
- 'skipped': False,
- }
- if answer_tag in post and post[answer_tag].strip():
- vals.update({'answer_type': 'free_text', 'value_free_text': post[answer_tag]})
- else:
- vals.update({'answer_type': None, 'skipped': True})
- old_uil = self.search([
- ('user_input_id', '=', user_input_id),
- ('survey_id', '=', question.survey_id.id),
- ('question_id', '=', question.id)
- ])
- if old_uil:
- old_uil.write(vals)
- else:
- old_uil.create(vals)
- return True
-
- @api.model
- def save_line_textbox(self, user_input_id, question, post, answer_tag):
- vals = {
- 'user_input_id': user_input_id,
- 'question_id': question.id,
- 'survey_id': question.survey_id.id,
- 'skipped': False
- }
- if answer_tag in post and post[answer_tag].strip():
- vals.update({'answer_type': 'text', 'value_text': post[answer_tag]})
- else:
- vals.update({'answer_type': None, 'skipped': True})
- old_uil = self.search([
- ('user_input_id', '=', user_input_id),
- ('survey_id', '=', question.survey_id.id),
- ('question_id', '=', question.id)
- ])
- if old_uil:
- old_uil.write(vals)
- else:
- old_uil.create(vals)
- return True
-
- @api.model
- def save_line_numerical_box(self, user_input_id, question, post, answer_tag):
- vals = {
- 'user_input_id': user_input_id,
- 'question_id': question.id,
- 'survey_id': question.survey_id.id,
- 'skipped': False
- }
- if answer_tag in post and post[answer_tag].strip():
- vals.update({'answer_type': 'number', 'value_number': float(post[answer_tag])})
- else:
- vals.update({'answer_type': None, 'skipped': True})
- old_uil = self.search([
- ('user_input_id', '=', user_input_id),
- ('survey_id', '=', question.survey_id.id),
- ('question_id', '=', question.id)
- ])
- if old_uil:
- old_uil.write(vals)
- else:
- old_uil.create(vals)
- return True
-
- @api.model
- def save_line_date(self, user_input_id, question, post, answer_tag):
- vals = {
- 'user_input_id': user_input_id,
- 'question_id': question.id,
- 'survey_id': question.survey_id.id,
- 'skipped': False
- }
- if answer_tag in post and post[answer_tag].strip():
- vals.update({'answer_type': 'date', 'value_date': post[answer_tag]})
- else:
- vals.update({'answer_type': None, 'skipped': True})
- old_uil = self.search([
- ('user_input_id', '=', user_input_id),
- ('survey_id', '=', question.survey_id.id),
- ('question_id', '=', question.id)
- ])
- if old_uil:
- old_uil.write(vals)
- else:
- old_uil.create(vals)
- return True
-
- @api.model
- def save_line_simple_choice(self, user_input_id, question, post, answer_tag):
- vals = {
- 'user_input_id': user_input_id,
- 'question_id': question.id,
- 'survey_id': question.survey_id.id,
- 'skipped': False
- }
- old_uil = self.search([
- ('user_input_id', '=', user_input_id),
- ('survey_id', '=', question.survey_id.id),
- ('question_id', '=', question.id)
- ])
- old_uil.sudo().unlink()
-
- if answer_tag in post and post[answer_tag].strip():
- vals.update({'answer_type': 'suggestion', 'value_suggested': post[answer_tag]})
- else:
- vals.update({'answer_type': None, 'skipped': True})
-
- # '-1' indicates 'comment count as an answer' so do not need to record it
- if post.get(answer_tag) and post.get(answer_tag) != '-1':
- self.create(vals)
-
- comment_answer = post.pop(("%s_%s" % (answer_tag, 'comment')), '').strip()
- if comment_answer:
- vals.update({'answer_type': 'text', 'value_text': comment_answer, 'skipped': False, 'value_suggested': False})
- self.create(vals)
-
- return True
-
- @api.model
- def save_line_multiple_choice(self, user_input_id, question, post, answer_tag):
- vals = {
- 'user_input_id': user_input_id,
- 'question_id': question.id,
- 'survey_id': question.survey_id.id,
- 'skipped': False
- }
- old_uil = self.search([
- ('user_input_id', '=', user_input_id),
- ('survey_id', '=', question.survey_id.id),
- ('question_id', '=', question.id)
- ])
- old_uil.sudo().unlink()
-
- ca_dict = dict_keys_startswith(post, answer_tag + '_')
- comment_answer = ca_dict.pop(("%s_%s" % (answer_tag, 'comment')), '').strip()
- if len(ca_dict) > 0:
- for key in ca_dict:
- # '-1' indicates 'comment count as an answer' so do not need to record it
- if key != ('%s_%s' % (answer_tag, '-1')):
- vals.update({'answer_type': 'suggestion', 'value_suggested': ca_dict[key]})
- self.create(vals)
- if comment_answer:
- vals.update({'answer_type': 'text', 'value_text': comment_answer, 'value_suggested': False})
- self.create(vals)
- if not ca_dict and not comment_answer:
- vals.update({'answer_type': None, 'skipped': True})
- self.create(vals)
- return True
-
- @api.model
- def save_line_matrix(self, user_input_id, question, post, answer_tag):
- vals = {
- 'user_input_id': user_input_id,
- 'question_id': question.id,
- 'survey_id': question.survey_id.id,
- 'skipped': False
- }
- old_uil = self.search([
- ('user_input_id', '=', user_input_id),
- ('survey_id', '=', question.survey_id.id),
- ('question_id', '=', question.id)
- ])
- old_uil.sudo().unlink()
-
- no_answers = True
- ca_dict = dict_keys_startswith(post, answer_tag + '_')
-
- comment_answer = ca_dict.pop(("%s_%s" % (answer_tag, 'comment')), '').strip()
- if comment_answer:
- vals.update({'answer_type': 'text', 'value_text': comment_answer})
- self.create(vals)
- no_answers = False
-
- if question.matrix_subtype == 'simple':
- for row in question.labels_ids_2:
- a_tag = "%s_%s" % (answer_tag, row.id)
- if a_tag in ca_dict:
- no_answers = False
- vals.update({'answer_type': 'suggestion', 'value_suggested': ca_dict[a_tag], 'value_suggested_row': row.id})
- self.create(vals)
-
- elif question.matrix_subtype == 'multiple':
- for col in question.labels_ids:
- for row in question.labels_ids_2:
- a_tag = "%s_%s_%s" % (answer_tag, row.id, col.id)
- if a_tag in ca_dict:
- no_answers = False
- vals.update({'answer_type': 'suggestion', 'value_suggested': col.id, 'value_suggested_row': row.id})
- self.create(vals)
- if no_answers:
- vals.update({'answer_type': None, 'skipped': True})
- self.create(vals)
- return True
diff --git a/addons/survey/models/survey_page.py b/addons/survey/models/survey_page.py
new file mode 100644
index 00000000000..b518ff01f16
--- /dev/null
+++ b/addons/survey/models/survey_page.py
@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models
+
+
+class SurveyPage(models.Model):
+ """ A page for a survey.
+
+ Pages are essentially containers, allowing to group questions by ordered
+ screens.
+
+ .. note::
+ A page should be deleted if the survey it belongs to is deleted.
+ """
+ _name = 'survey.page'
+ _description = 'Survey Page'
+ _rec_name = 'title'
+ _order = 'sequence,id'
+
+ # Model Fields #
+
+ title = fields.Char('Page Title', required=True, translate=True)
+ survey_id = fields.Many2one('survey.survey', string='Survey', ondelete='cascade', required=True)
+ question_ids = fields.One2many('survey.question', 'page_id', string='Questions', copy=True)
+ sequence = fields.Integer('Page number', default=10)
+ description = fields.Html('Description', translate=True, oldname="note", help="An introductory text to your page") \ No newline at end of file
diff --git a/addons/survey/models/survey_question.py b/addons/survey/models/survey_question.py
new file mode 100644
index 00000000000..3f207c2719e
--- /dev/null
+++ b/addons/survey/models/survey_question.py
@@ -0,0 +1,299 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import logging
+import re
+
+from odoo import api, fields, models, tools, _
+from odoo.exceptions import ValidationError
+
+email_validator = re.compile(r"[^@]+@[^@]+\.[^@]+")
+_logger = logging.getLogger(__name__)
+
+
+def dict_keys_startswith(dictionary, string):
+ """Returns a dictionary containing the elements of <dict> whose keys start with <string>.
+ .. note::
+ This function uses dictionary comprehensions (Python >= 2.7)
+ """
+ return {k: v for k, v in dictionary.items() if k.startswith(string)}
+
+
+class SurveyQuestion(models.Model):
+ """ Questions that will be asked in a survey.
+
+ Each question can have one of more suggested answers (eg. in case of
+ dropdown choices, multi-answer checkboxes, radio buttons...).
+ """
+
+ _name = 'survey.question'
+ _description = 'Survey Question'
+ _rec_name = 'question'
+ _order = 'sequence,id'
+
+ # Model fields #
+
+ # Question metadata
+ page_id = fields.Many2one('survey.page', string='Survey page',
+ ondelete='cascade', required=True, default=lambda self: self.env.context.get('page_id'))
+ survey_id = fields.Many2one('survey.survey', related='page_id.survey_id', string='Survey', readonly=False)
+ sequence = fields.Integer('Sequence', default=10)
+
+ # Question
+ question = fields.Char('Question Name', required=True, translate=True)
+ description = fields.Html('Description', help="Use this field to add \
+ additional explanations about your question", translate=True,
+ oldname='descriptive_text')
+
+ # Answer
+ question_type = fields.Selection([
+ ('free_text', 'Multiple Lines Text Box'),
+ ('textbox', 'Single Line Text Box'),
+ ('numerical_box', 'Numerical Value'),
+ ('date', 'Date'),
+ ('simple_choice', 'Multiple choice: only one answer'),
+ ('multiple_choice', 'Multiple choice: multiple answers allowed'),
+ ('matrix', 'Matrix')], string='Type of Question', default='free_text', required=True, oldname='type')
+ matrix_subtype = fields.Selection([('simple', 'One choice per row'),
+ ('multiple', 'Multiple choices per row')], string='Matrix Type', default='simple')
+ labels_ids = fields.One2many('survey.label', 'question_id', string='Types of answers', oldname='answer_choice_ids', copy=True)
+ labels_ids_2 = fields.One2many('survey.label', 'question_id_2', string='Rows of the Matrix', copy=True)
+ # labels are used for proposed choices
+ # if question.type == simple choice | multiple choice
+ # -> only labels_ids is used
+ # if question.type == matrix
+ # -> labels_ids are the columns of the matrix
+ # -> labels_ids_2 are the rows of the matrix
+
+ # Display options
+ column_nb = fields.Selection([('12', '1'),
+ ('6', '2'),
+ ('4', '3'),
+ ('3', '4'),
+ ('2', '6')],
+ 'Number of columns', default='12')
+ # These options refer to col-xx-[12|6|4|3|2] classes in Bootstrap
+ display_mode = fields.Selection([('columns', 'Radio Buttons'),
+ ('dropdown', 'Selection Box')],
+ default='columns')
+
+ # Comments
+ comments_allowed = fields.Boolean('Show Comments Field',
+ oldname="allow_comment")
+ comments_message = fields.Char('Comment Message', translate=True, default=lambda self: _("If other, please specify:"))
+ comment_count_as_answer = fields.Boolean('Comment Field is an Answer Choice',
+ oldname='make_comment_field')
+
+ # Validation
+ validation_required = fields.Boolean('Validate entry', oldname='is_validation_require')
+ validation_email = fields.Boolean('Input must be an email')
+ validation_length_min = fields.Integer('Minimum Text Length')
+ validation_length_max = fields.Integer('Maximum Text Length')
+ validation_min_float_value = fields.Float('Minimum value')
+ validation_max_float_value = fields.Float('Maximum value')
+ validation_min_date = fields.Date('Minimum Date')
+ validation_max_date = fields.Date('Maximum Date')
+ validation_error_msg = fields.Char('Validation Error message', oldname='validation_valid_err_msg',
+ translate=True, default=lambda self: _("The answer you entered has an invalid format."))
+
+ # Constraints on number of answers (matrices)
+ constr_mandatory = fields.Boolean('Mandatory Answer', oldname="is_require_answer")
+ constr_error_msg = fields.Char('Error message', oldname='req_error_msg', translate=True, default=lambda self: _("This question requires an answer."))
+ user_input_line_ids = fields.One2many('survey.user_input_line', 'question_id', string='Answers', domain=[('skipped', '=', False)])
+
+ _sql_constraints = [
+ ('positive_len_min', 'CHECK (validation_length_min >= 0)', 'A length must be positive!'),
+ ('positive_len_max', 'CHECK (validation_length_max >= 0)', 'A length must be positive!'),
+ ('validation_length', 'CHECK (validation_length_min <= validation_length_max)', 'Max length cannot be smaller than min length!'),
+ ('validation_float', 'CHECK (validation_min_float_value <= validation_max_float_value)', 'Max value cannot be smaller than min value!'),
+ ('validation_date', 'CHECK (validation_min_date <= validation_max_date)', 'Max date cannot be smaller than min date!')
+ ]
+
+ @api.onchange('validation_email')
+ def onchange_validation_email(self):
+ if self.validation_email:
+ self.validation_required = False
+
+ # Validation methods
+
+ @api.multi
+ def validate_question(self, post, answer_tag):
+ """ Validate question, depending on question type and parameters """
+ self.ensure_one()
+ try:
+ checker = getattr(self, 'validate_' + self.question_type)
+ except AttributeError:
+ _logger.warning(self.question_type + ": This type of question has no validation method")
+ return {}
+ else:
+ return checker(post, answer_tag)
+
+ @api.multi
+ def validate_free_text(self, post, answer_tag):
+ self.ensure_one()
+ errors = {}
+ answer = post[answer_tag].strip()
+ # Empty answer to mandatory question
+ if self.constr_mandatory and not answer:
+ errors.update({answer_tag: self.constr_error_msg})
+ return errors
+
+ @api.multi
+ def validate_textbox(self, post, answer_tag):
+ self.ensure_one()
+ errors = {}
+ answer = post[answer_tag].strip()
+ # Empty answer to mandatory question
+ if self.constr_mandatory and not answer:
+ errors.update({answer_tag: self.constr_error_msg})
+ # Email format validation
+ # Note: this validation is very basic:
+ # all the strings of the form
+ # <something>@<anything>.<extension>
+ # will be accepted
+ if answer and self.validation_email:
+ if not email_validator.match(answer):
+ errors.update({answer_tag: _('This answer must be an email address')})
+ # Answer validation (if properly defined)
+ # Length of the answer must be in a range
+ if answer and self.validation_required:
+ if not (self.validation_length_min <= len(answer) <= self.validation_length_max):
+ errors.update({answer_tag: self.validation_error_msg})
+ return errors
+
+ @api.multi
+ def validate_numerical_box(self, post, answer_tag):
+ self.ensure_one()
+ errors = {}
+ answer = post[answer_tag].strip()
+ # Empty answer to mandatory question
+ if self.constr_mandatory and not answer:
+ errors.update({answer_tag: self.constr_error_msg})
+ # Checks if user input is a number
+ if answer:
+ try:
+ floatanswer = float(answer)
+ except ValueError:
+ errors.update({answer_tag: _('This is not a number')})
+ # Answer validation (if properly defined)
+ if answer and self.validation_required:
+ # Answer is not in the right range
+ with tools.ignore(Exception):
+ floatanswer = float(answer) # check that it is a float has been done hereunder
+ if not (self.validation_min_float_value <= floatanswer <= self.validation_max_float_value):
+ errors.update({answer_tag: self.validation_error_msg})
+ return errors
+
+ @api.multi
+ def validate_date(self, post, answer_tag):
+ self.ensure_one()
+ errors = {}
+ answer = post[answer_tag].strip()
+ # Empty answer to mandatory question
+ if self.constr_mandatory and not answer:
+ errors.update({answer_tag: self.constr_error_msg})
+ # Checks if user input is a date
+ if answer:
+ try:
+ dateanswer = fields.Date.from_string(answer)
+ except ValueError:
+ errors.update({answer_tag: _('This is not a date')})
+ return errors
+ # Answer validation (if properly defined)
+ if answer and self.validation_required:
+ # Answer is not in the right range
+ try:
+ date_from_string = fields.Date.from_string
+ dateanswer = date_from_string(answer)
+ min_date = date_from_string(self.validation_min_date)
+ max_date = date_from_string(self.validation_max_date)
+
+ if min_date and max_date and not (min_date <= dateanswer <= max_date):
+ # If Minimum and Maximum Date are entered
+ errors.update({answer_tag: self.validation_error_msg})
+ elif min_date and not min_date <= dateanswer:
+ # If only Minimum Date is entered and not Define Maximum Date
+ errors.update({answer_tag: self.validation_error_msg})
+ elif max_date and not dateanswer <= max_date:
+ # If only Maximum Date is entered and not Define Minimum Date
+ errors.update({answer_tag: self.validation_error_msg})
+ except ValueError: # check that it is a date has been done hereunder
+ pass
+ return errors
+
+ @api.multi
+ def validate_simple_choice(self, post, answer_tag):
+ self.ensure_one()
+ errors = {}
+ if self.comments_allowed:
+ comment_tag = "%s_%s" % (answer_tag, 'comment')
+ # Empty answer to mandatory self
+ if self.constr_mandatory and answer_tag not in post:
+ errors.update({answer_tag: self.constr_error_msg})
+ if self.constr_mandatory and answer_tag in post and not post[answer_tag].strip():
+ errors.update({answer_tag: self.constr_error_msg})
+ # Answer is a comment and is empty
+ if self.constr_mandatory and answer_tag in post and post[answer_tag] == "-1" and self.comment_count_as_answer and comment_tag in post and not post[comment_tag].strip():
+ errors.update({answer_tag: self.constr_error_msg})
+ return errors
+
+ @api.multi
+ def validate_multiple_choice(self, post, answer_tag):
+ self.ensure_one()
+ errors = {}
+ if self.constr_mandatory:
+ answer_candidates = dict_keys_startswith(post, answer_tag)
+ comment_flag = answer_candidates.pop(("%s_%s" % (answer_tag, -1)), None)
+ if self.comments_allowed:
+ comment_answer = answer_candidates.pop(("%s_%s" % (answer_tag, 'comment')), '').strip()
+ # Preventing answers with blank value
+ if all(not answer.strip() for answer in answer_candidates.values()) and answer_candidates:
+ errors.update({answer_tag: self.constr_error_msg})
+ # There is no answer neither comments (if comments count as answer)
+ if not answer_candidates and self.comment_count_as_answer and (not comment_flag or not comment_answer):
+ errors.update({answer_tag: self.constr_error_msg})
+ # There is no answer at all
+ if not answer_candidates and not self.comment_count_as_answer:
+ errors.update({answer_tag: self.constr_error_msg})
+ return errors
+
+ @api.multi
+ def validate_matrix(self, post, answer_tag):
+ self.ensure_one()
+ errors = {}
+ if self.constr_mandatory:
+ lines_number = len(self.labels_ids_2)
+ answer_candidates = dict_keys_startswith(post, answer_tag)
+ answer_candidates.pop(("%s_%s" % (answer_tag, 'comment')), '').strip()
+ # Number of lines that have been answered
+ if self.matrix_subtype == 'simple':
+ answer_number = len(answer_candidates)
+ elif self.matrix_subtype == 'multiple':
+ answer_number = len({sk.rsplit('_', 1)[0] for sk in answer_candidates})
+ else:
+ raise RuntimeError("Invalid matrix subtype")
+ # Validate that each line has been answered
+ if answer_number != lines_number:
+ errors.update({answer_tag: self.constr_error_msg})
+ return errors
+
+class SurveyLabel(models.Model):
+ """ A suggested answer for a question """
+
+ _name = 'survey.label'
+ _rec_name = 'value'
+ _order = 'sequence,id'
+ _description = 'Survey Label'
+
+ question_id = fields.Many2one('survey.question', string='Question', ondelete='cascade')
+ question_id_2 = fields.Many2one('survey.question', string='Question 2', ondelete='cascade')
+ sequence = fields.Integer('Label Sequence order', default=10)
+ value = fields.Char('Suggested value', translate=True, required=True)
+ quizz_mark = fields.Float('Score for this choice', help="A positive score indicates a correct choice; a negative or null score indicates a wrong answer")
+
+ @api.one
+ @api.constrains('question_id', 'question_id_2')
+ def _check_question_not_empty(self):
+ """Ensure that field question_id XOR field question_id_2 is not null"""
+ if not bool(self.question_id) != bool(self.question_id_2):
+ raise ValidationError(_("A label must be attached to only one question."))
diff --git a/addons/survey/models/survey_stage.py b/addons/survey/models/survey_stage.py
new file mode 100644
index 00000000000..b73e28fbc51
--- /dev/null
+++ b/addons/survey/models/survey_stage.py
@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models
+
+
+class SurveyStage(models.Model):
+ """Stages for Kanban view of surveys"""
+
+ _name = 'survey.stage'
+ _description = 'Survey Stage'
+ _order = 'sequence,id'
+
+ name = fields.Char(required=True, translate=True)
+ sequence = fields.Integer(default=1)
+ closed = fields.Boolean(help="If closed, people won't be able to answer to surveys in this column.")
+ fold = fields.Boolean(string="Folded in kanban view")
+
+ _sql_constraints = [
+ ('positive_sequence', 'CHECK(sequence >= 0)', 'Sequence number MUST be a natural')
+ ]
diff --git a/addons/survey/models/survey_survey.py b/addons/survey/models/survey_survey.py
new file mode 100644
index 00000000000..65955df273e
--- /dev/null
+++ b/addons/survey/models/survey_survey.py
@@ -0,0 +1,354 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from collections import Counter, OrderedDict
+from itertools import product
+from werkzeug import urls
+
+from odoo import api, fields, models, SUPERUSER_ID, _
+from odoo.addons.http_routing.models.ir_http import slug
+from odoo.exceptions import UserError
+
+
+class Survey(models.Model):
+ """ Settings for a multi-page/multi-question survey.
+ Each survey can have one or more attached pages, and each page can display
+ one or more questions.
+ """
+
+ _name = 'survey.survey'
+ _description = 'Survey'
+ _rec_name = 'title'
+ _inherit = ['mail.thread', 'mail.activity.mixin']
+
+ def _default_stage(self):
+ return self.env['survey.stage'].search([], limit=1).id
+
+ title = fields.Char('Title', required=True, translate=True)
+ page_ids = fields.One2many('survey.page', 'survey_id', string='Pages', copy=True)
+ stage_id = fields.Many2one('survey.stage', string="Stage", default=_default_stage,
+ ondelete="restrict", copy=False, group_expand='_read_group_stage_ids')
+ auth_required = fields.Boolean('Login required', help="Users with a public link will be requested to login before taking part to the survey",
+ oldname="authenticate")
+ users_can_go_back = fields.Boolean('Users can go back', help="If checked, users can go back to previous pages.")
+ tot_sent_survey = fields.Integer("Number of sent surveys", compute="_compute_survey_statistic")
+ tot_start_survey = fields.Integer("Number of started surveys", compute="_compute_survey_statistic")
+ tot_comp_survey = fields.Integer("Number of completed surveys", compute="_compute_survey_statistic")
+ description = fields.Html("Description", translate=True, help="A long description of the purpose of the survey")
+ color = fields.Integer('Color Index', default=0)
+ user_input_ids = fields.One2many('survey.user_input', 'survey_id', string='User responses', readonly=True)
+ designed = fields.Boolean("Is designed?", compute="_is_designed")
+ public_url = fields.Char("Public link", compute="_compute_survey_url")
+ public_url_html = fields.Char("Public link (html version)", compute="_compute_survey_url")
+ print_url = fields.Char("Print link", compute="_compute_survey_url")
+ result_url = fields.Char("Results link", compute="_compute_survey_url")
+ email_template_id = fields.Many2one('mail.template', string='Email Template', ondelete='set null')
+ thank_you_message = fields.Html("Thanks Message", translate=True, help="This message will be displayed when survey is completed")
+ quizz_mode = fields.Boolean("Quizz Mode")
+ active = fields.Boolean("Active", default=True)
+ is_closed = fields.Boolean("Is closed", related='stage_id.closed', readonly=False)
+
+ def _is_designed(self):
+ for survey in self:
+ if not survey.page_ids or not [page.question_ids for page in survey.page_ids if page.question_ids]:
+ survey.designed = False
+ else:
+ survey.designed = True
+
+ @api.multi
+ def _compute_survey_statistic(self):
+ UserInput = self.env['survey.user_input']
+
+ sent_survey = UserInput.search([('survey_id', 'in', self.ids), ('input_type', '=', 'link')])
+ start_survey = UserInput.search(['&', ('survey_id', 'in', self.ids), '|', ('state', '=', 'skip'), ('state', '=', 'done')])
+ complete_survey = UserInput.search([('survey_id', 'in', self.ids), ('state', '=', 'done')])
+
+ for survey in self:
+ survey.tot_sent_survey = len(sent_survey.filtered(lambda user_input: user_input.survey_id == survey))
+ survey.tot_start_survey = len(start_survey.filtered(lambda user_input: user_input.survey_id == survey))
+ survey.tot_comp_survey = len(complete_survey.filtered(lambda user_input: user_input.survey_id == survey))
+
+ def _compute_survey_url(self):
+ """ Computes a public URL for the survey """
+ base_url = '/' if self.env.context.get('relative_url') else \
+ self.env['ir.config_parameter'].sudo().get_param('web.base.url')
+ for survey in self:
+ survey.public_url = urls.url_join(base_url, "survey/start/%s" % (slug(survey)))
+ survey.print_url = urls.url_join(base_url, "survey/print/%s" % (slug(survey)))
+ survey.result_url = urls.url_join(base_url, "survey/results/%s" % (slug(survey)))
+ survey.public_url_html = '<a href="%s">%s</a>' % (survey.public_url, _("Click here to start survey"))
+
+ @api.model
+ def _read_group_stage_ids(self, stages, domain, order):
+ """ Read group customization in order to display all the stages in the
+ kanban view, even if they are empty
+ """
+ stage_ids = stages._search([], order=order, access_rights_uid=SUPERUSER_ID)
+ return stages.browse(stage_ids)
+
+ # Public methods #
+ def copy_data(self, default=None):
+ title = _("%s (copy)") % (self.title)
+ default = dict(default or {}, title=title)
+ return super(Survey, self).copy_data(default)
+
+ @api.model
+ def next_page(self, user_input, page_id, go_back=False):
+ """ The next page to display to the user, knowing that page_id is the id
+ of the last displayed page.
+
+ If page_id == 0, it will always return the first page of the survey.
+
+ If all the pages have been displayed and go_back == False, it will
+ return None
+
+ If go_back == True, it will return the *previous* page instead of the
+ next page.
+
+ .. note::
+ It is assumed here that a careful user will not try to set go_back
+ to True if she knows that the page to display is the first one!
+ (doing this will probably cause a giant worm to eat her house)
+ """
+ survey = user_input.survey_id
+ pages = list(enumerate(survey.page_ids))
+
+ # First page
+ if page_id == 0:
+ return (pages[0][1], 0, len(pages) == 1)
+
+ current_page_index = pages.index(next(p for p in pages if p[1].id == page_id))
+
+ # All the pages have been displayed
+ if current_page_index == len(pages) - 1 and not go_back:
+ return (None, -1, False)
+ # Let's get back, baby!
+ elif go_back and survey.users_can_go_back:
+ return (pages[current_page_index - 1][1], current_page_index - 1, False)
+ else:
+ # This will show the last page
+ if current_page_index == len(pages) - 2:
+ return (pages[current_page_index + 1][1], current_page_index + 1, True)
+ # This will show a regular page
+ else:
+ return (pages[current_page_index + 1][1], current_page_index + 1, False)
+
+ @api.multi
+ def filter_input_ids(self, filters, finished=False):
+ """If user applies any filters, then this function returns list of
+ filtered user_input_id and label's strings for display data in web.
+ :param filters: list of dictionary (having: row_id, ansewr_id)
+ :param finished: True for completely filled survey,Falser otherwise.
+ :returns list of filtered user_input_ids.
+ """
+ self.ensure_one()
+ if filters:
+ domain_filter, choice = [], []
+ for current_filter in filters:
+ row_id, answer_id = current_filter['row_id'], current_filter['answer_id']
+ if row_id == 0:
+ choice.append(answer_id)
+ else:
+ domain_filter.extend(['|', ('value_suggested_row.id', '=', row_id), ('value_suggested.id', '=', answer_id)])
+ if choice:
+ domain_filter.insert(0, ('value_suggested.id', 'in', choice))
+ else:
+ domain_filter = domain_filter[1:]
+ input_lines = self.env['survey.user_input_line'].search(domain_filter)
+ filtered_input_ids = [input_line.user_input_id.id for input_line in input_lines]
+ else:
+ filtered_input_ids = []
+ if finished:
+ UserInput = self.env['survey.user_input']
+ if not filtered_input_ids:
+ user_inputs = UserInput.search([('survey_id', '=', self.id)])
+ else:
+ user_inputs = UserInput.browse(filtered_input_ids)
+ return user_inputs.filtered(lambda input_item: input_item.state == 'done').ids
+ return filtered_input_ids
+
+ @api.model
+ def get_filter_display_data(self, filters):
+ """Returns data to display current filters
+ :param filters: list of dictionary (having: row_id, answer_id)
+ :returns list of dict having data to display filters.
+ """
+ filter_display_data = []
+ if filters:
+ Label = self.env['survey.label']
+ for current_filter in filters:
+ row_id, answer_id = current_filter['row_id'], current_filter['answer_id']
+ label = Label.browse(answer_id)
+ question = label.question_id
+ if row_id == 0:
+ labels = label
+ else:
+ labels = Label.browse([row_id, answer_id])
+ filter_display_data.append({'question_text': question.question,
+ 'labels': labels.mapped('value')})
+ return filter_display_data
+
+ @api.model
+ def prepare_result(self, question, current_filters=None):
+ """ Compute statistical data for questions by counting number of vote per choice on basis of filter """
+ current_filters = current_filters if current_filters else []
+ result_summary = {}
+
+ # Calculate and return statistics for choice
+ if question.question_type in ['simple_choice', 'multiple_choice']:
+ comments = []
+ answers = OrderedDict((label.id, {'text': label.value, 'count': 0, 'answer_id': label.id}) for label in question.labels_ids)
+ for input_line in question.user_input_line_ids:
+ if input_line.answer_type == 'suggestion' and answers.get(input_line.value_suggested.id) and (not(current_filters) or input_line.user_input_id.id in current_filters):
+ answers[input_line.value_suggested.id]['count'] += 1
+ if input_line.answer_type == 'text' and (not(current_filters) or input_line.user_input_id.id in current_filters):
+ comments.append(input_line)
+ result_summary = {'answers': list(answers.values()), 'comments': comments}
+
+ # Calculate and return statistics for matrix
+ if question.question_type == 'matrix':
+ rows = OrderedDict()
+ answers = OrderedDict()
+ res = dict()
+ comments = []
+ [rows.update({label.id: label.value}) for label in question.labels_ids_2]
+ [answers.update({label.id: label.value}) for label in question.labels_ids]
+ for cell in product(rows, answers):
+ res[cell] = 0
+ for input_line in question.user_input_line_ids:
+ if input_line.answer_type == 'suggestion' and (not(current_filters) or input_line.user_input_id.id in current_filters) and input_line.value_suggested_row:
+ res[(input_line.value_suggested_row.id, input_line.value_suggested.id)] += 1
+ if input_line.answer_type == 'text' and (not(current_filters) or input_line.user_input_id.id in current_filters):
+ comments.append(input_line)
+ result_summary = {'answers': answers, 'rows': rows, 'result': res, 'comments': comments}
+
+ # Calculate and return statistics for free_text, textbox, date
+ if question.question_type in ['free_text', 'textbox', 'date']:
+ result_summary = []
+ for input_line in question.user_input_line_ids:
+ if not(current_filters) or input_line.user_input_id.id in current_filters:
+ result_summary.append(input_line)
+
+ # Calculate and return statistics for numerical_box
+ if question.question_type == 'numerical_box':
+ result_summary = {'input_lines': []}
+ all_inputs = []
+ for input_line in question.user_input_line_ids:
+ if not(current_filters) or input_line.user_input_id.id in current_filters:
+ all_inputs.append(input_line.value_number)
+ result_summary['input_lines'].append(input_line)
+ if all_inputs:
+ result_summary.update({'average': round(sum(all_inputs) / len(all_inputs), 2),
+ 'max': round(max(all_inputs), 2),
+ 'min': round(min(all_inputs), 2),
+ 'sum': sum(all_inputs),
+ 'most_common': Counter(all_inputs).most_common(5)})
+ return result_summary
+
+ @api.model
+ def get_input_summary(self, question, current_filters=None):
+ """ Returns overall summary of question e.g. answered, skipped, total_inputs on basis of filter """
+ current_filters = current_filters if current_filters else []
+ result = {}
+ if question.survey_id.user_input_ids:
+ total_input_ids = current_filters or [input_id.id for input_id in question.survey_id.user_input_ids if input_id.state != 'new']
+ result['total_inputs'] = len(total_input_ids)
+ question_input_ids = []
+ for user_input in question.user_input_line_ids:
+ if not user_input.skipped:
+ question_input_ids.append(user_input.user_input_id.id)
+ result['answered'] = len(set(question_input_ids) & set(total_input_ids))
+ result['skipped'] = result['total_inputs'] - result['answered']
+ return result
+
+ # Actions
+
+ @api.multi
+ def action_start_survey(self):
+ """ Open the website page with the survey form """
+ self.ensure_one()
+ token = self.env.context.get('survey_token')
+ trail = "/%s" % token if token else ""
+ return {
+ 'type': 'ir.actions.act_url',
+ 'name': "Start Survey",
+ 'target': 'self',
+ 'url': self.with_context(relative_url=True).public_url + trail
+ }
+
+ @api.multi
+ def action_send_survey(self):
+ """ Open a window to compose an email, pre-filled with the survey message """
+ # Ensure that this survey has at least one page with at least one question.
+ if not self.page_ids or not [page.question_ids for page in self.page_ids if page.question_ids]:
+ raise UserError(_('You cannot send an invitation for a survey that has no questions.'))
+
+ if self.stage_id.closed:
+ raise UserError(_("You cannot send invitations for closed surveys."))
+
+ template = self.env.ref('survey.email_template_survey', raise_if_not_found=False)
+
+ local_context = dict(
+ self.env.context,
+ default_model='survey.survey',
+ default_res_id=self.id,
+ default_survey_id=self.id,
+ default_use_template=bool(template),
+ default_template_id=template and template.id or False,
+ default_composition_mode='comment',
+ notif_layout='mail.mail_notification_light',
+ )
+ return {
+ 'type': 'ir.actions.act_window',
+ 'view_type': 'form',
+ 'view_mode': 'form',
+ 'res_model': 'survey.mail.compose.message',
+ 'target': 'new',
+ 'context': local_context,
+ }
+
+ @api.multi
+ def action_print_survey(self):
+ """ Open the website page with the survey printable view """
+ self.ensure_one()
+ token = self.env.context.get('survey_token')
+ trail = "/" + token if token else ""
+ return {
+ 'type': 'ir.actions.act_url',
+ 'name': "Print Survey",
+ 'target': 'self',
+ 'url': self.with_context(relative_url=True).print_url + trail
+ }
+
+ @api.multi
+ def action_result_survey(self):
+ """ Open the website page with the survey results view """
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_url',
+ 'name': "Results of the Survey",
+ 'target': 'self',
+ 'url': self.with_context(relative_url=True).result_url
+ }
+
+ @api.multi
+ def action_test_survey(self):
+ ''' Open the website page with the survey form into test mode'''
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_url',
+ 'name': "Results of the Survey",
+ 'target': 'self',
+ 'url': self.with_context(relative_url=True).public_url + "/phantom"
+ }
+
+ @api.multi
+ def action_survey_user_input(self):
+ action_rec = self.env.ref('survey.action_survey_user_input')
+ action = action_rec.read()[0]
+ ctx = dict(self.env.context)
+ ctx.update({'search_default_survey_id': self.ids[0],
+ 'search_default_completed': 1})
+ action['context'] = ctx
+ return action
+
diff --git a/addons/survey/models/survey_user.py b/addons/survey/models/survey_user.py
new file mode 100644
index 00000000000..32250825bd3
--- /dev/null
+++ b/addons/survey/models/survey_user.py
@@ -0,0 +1,386 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import datetime
+import logging
+import re
+import uuid
+
+from odoo import api, fields, models, _
+from odoo.exceptions import ValidationError
+
+email_validator = re.compile(r"[^@]+@[^@]+\.[^@]+")
+_logger = logging.getLogger(__name__)
+
+
+def dict_keys_startswith(dictionary, string):
+ """Returns a dictionary containing the elements of <dict> whose keys start with <string>.
+ .. note::
+ This function uses dictionary comprehensions (Python >= 2.7)
+ """
+ return {k: v for k, v in dictionary.items() if k.startswith(string)}
+
+
+class SurveyUserInput(models.Model):
+ """ Metadata for a set of one user's answers to a particular survey """
+
+ _name = "survey.user_input"
+ _rec_name = 'date_create'
+ _description = 'Survey User Input'
+
+ survey_id = fields.Many2one('survey.survey', string='Survey', required=True, readonly=True, ondelete='restrict')
+ date_create = fields.Datetime('Creation Date', default=fields.Datetime.now, required=True, readonly=True, copy=False)
+ deadline = fields.Datetime('Deadline', help="Date by which the person can open the survey and submit answers", oldname="date_deadline")
+ input_type = fields.Selection([('manually', 'Manually'), ('link', 'Link')], string='Answer Type', default='manually', required=True, readonly=True, oldname="type")
+ state = fields.Selection([
+ ('new', 'Not started yet'),
+ ('skip', 'Partially completed'),
+ ('done', 'Completed')], string='Status', default='new', readonly=True)
+ test_entry = fields.Boolean(readonly=True)
+ token = fields.Char('Identification token', default=lambda self: str(uuid.uuid4()), readonly=True, required=True, copy=False)
+
+ # Optional Identification data
+ partner_id = fields.Many2one('res.partner', string='Partner', readonly=True)
+ email = fields.Char('E-mail', readonly=True)
+
+ # Displaying data
+ last_displayed_page_id = fields.Many2one('survey.page', string='Last displayed page')
+ # The answers !
+ user_input_line_ids = fields.One2many('survey.user_input_line', 'user_input_id', string='Answers', copy=True)
+
+ # URLs used to display the answers
+ result_url = fields.Char("Public link to the survey results", related='survey_id.result_url', readonly=False)
+ print_url = fields.Char("Public link to the empty survey", related='survey_id.print_url', readonly=False)
+
+ quizz_score = fields.Float("Score for the quiz", compute="_compute_quizz_score", default=0.0)
+
+ @api.depends('user_input_line_ids.quizz_mark')
+ def _compute_quizz_score(self):
+ for user_input in self:
+ user_input.quizz_score = sum(user_input.user_input_line_ids.mapped('quizz_mark'))
+
+ _sql_constraints = [
+ ('unique_token', 'UNIQUE (token)', 'A token must be unique!'),
+ ]
+
+ @api.model
+ def do_clean_emptys(self):
+ """ Remove empty user inputs that have been created manually
+ (used as a cronjob declared in data/survey_cron.xml)
+ """
+ an_hour_ago = fields.Datetime.to_string(datetime.datetime.now() - datetime.timedelta(hours=1))
+ self.search([('input_type', '=', 'manually'), ('state', '=', 'new'),
+ ('date_create', '<', an_hour_ago)]).unlink()
+
+ @api.multi
+ def action_survey_resend(self):
+ """ Send again the invitation """
+ self.ensure_one()
+ local_context = {
+ 'survey_resent_token': True,
+ 'default_partner_ids': self.partner_id and [self.partner_id.id] or [],
+ 'default_multi_email': self.email or "",
+ 'default_public': 'email_private',
+ }
+ return self.survey_id.with_context(local_context).action_send_survey()
+
+ @api.multi
+ def action_view_answers(self):
+ """ Open the website page with the survey form """
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_url',
+ 'name': "View Answers",
+ 'target': 'self',
+ 'url': '%s/%s' % (self.print_url, self.token)
+ }
+
+ @api.multi
+ def action_survey_results(self):
+ """ Open the website page with the survey results """
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_url',
+ 'name': "Survey Results",
+ 'target': 'self',
+ 'url': self.result_url
+ }
+
+
+class SurveyUserInputLine(models.Model):
+ _name = 'survey.user_input_line'
+ _description = 'Survey User Input Line'
+ _rec_name = 'date_create'
+
+ user_input_id = fields.Many2one('survey.user_input', string='User Input', ondelete='cascade', required=True)
+ question_id = fields.Many2one('survey.question', string='Question', ondelete='restrict', required=True)
+ page_id = fields.Many2one(related='question_id.page_id', string="Page", readonly=False)
+ survey_id = fields.Many2one(related='user_input_id.survey_id', string='Survey', store=True, readonly=False)
+ date_create = fields.Datetime('Create Date', default=fields.Datetime.now, required=True)
+ skipped = fields.Boolean('Skipped')
+ answer_type = fields.Selection([
+ ('text', 'Text'),
+ ('number', 'Number'),
+ ('date', 'Date'),
+ ('free_text', 'Free Text'),
+ ('suggestion', 'Suggestion')], string='Answer Type')
+ value_text = fields.Char('Text answer')
+ value_number = fields.Float('Numerical answer')
+ value_date = fields.Date('Date answer')
+ value_free_text = fields.Text('Free Text answer')
+ value_suggested = fields.Many2one('survey.label', string="Suggested answer")
+ value_suggested_row = fields.Many2one('survey.label', string="Row answer")
+ quizz_mark = fields.Float('Score given for this choice')
+
+ @api.constrains('skipped', 'answer_type')
+ def _answered_or_skipped(self):
+ for uil in self:
+ if not uil.skipped != bool(uil.answer_type):
+ raise ValidationError(_('This question cannot be unanswered or skipped.'))
+
+ @api.constrains('answer_type')
+ def _check_answer_type(self):
+ for uil in self:
+ fields_type = {
+ 'text': bool(uil.value_text),
+ 'number': (bool(uil.value_number) or uil.value_number == 0),
+ 'date': bool(uil.value_date),
+ 'free_text': bool(uil.value_free_text),
+ 'suggestion': bool(uil.value_suggested)
+ }
+ if not fields_type.get(uil.answer_type, True):
+ raise ValidationError(_('The answer must be in the right type'))
+
+ def _get_mark(self, value_suggested):
+ label = self.env['survey.label'].browse(int(value_suggested))
+ mark = label.quizz_mark if label.exists() else 0.0
+ return mark
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ for vals in vals_list:
+ value_suggested = vals.get('value_suggested')
+ if value_suggested:
+ vals.update({'quizz_mark': self._get_mark(value_suggested)})
+ return super(SurveyUserInputLine, self).create(vals_list)
+
+ @api.multi
+ def write(self, vals):
+ value_suggested = vals.get('value_suggested')
+ if value_suggested:
+ vals.update({'quizz_mark': self._get_mark(value_suggested)})
+ return super(SurveyUserInputLine, self).write(vals)
+
+ @api.model
+ def save_lines(self, user_input_id, question, post, answer_tag):
+ """ Save answers to questions, depending on question type
+
+ If an answer already exists for question and user_input_id, it will be
+ overwritten (in order to maintain data consistency).
+ """
+ try:
+ saver = getattr(self, 'save_line_' + question.question_type)
+ except AttributeError:
+ _logger.error(question.question_type + ": This type of question has no saving function")
+ return False
+ else:
+ saver(user_input_id, question, post, answer_tag)
+
+ @api.model
+ def save_line_free_text(self, user_input_id, question, post, answer_tag):
+ vals = {
+ 'user_input_id': user_input_id,
+ 'question_id': question.id,
+ 'survey_id': question.survey_id.id,
+ 'skipped': False,
+ }
+ if answer_tag in post and post[answer_tag].strip():
+ vals.update({'answer_type': 'free_text', 'value_free_text': post[answer_tag]})
+ else:
+ vals.update({'answer_type': None, 'skipped': True})
+ old_uil = self.search([
+ ('user_input_id', '=', user_input_id),
+ ('survey_id', '=', question.survey_id.id),
+ ('question_id', '=', question.id)
+ ])
+ if old_uil:
+ old_uil.write(vals)
+ else:
+ old_uil.create(vals)
+ return True
+
+ @api.model
+ def save_line_textbox(self, user_input_id, question, post, answer_tag):
+ vals = {
+ 'user_input_id': user_input_id,
+ 'question_id': question.id,
+ 'survey_id': question.survey_id.id,
+ 'skipped': False
+ }
+ if answer_tag in post and post[answer_tag].strip():
+ vals.update({'answer_type': 'text', 'value_text': post[answer_tag]})
+ else:
+ vals.update({'answer_type': None, 'skipped': True})
+ old_uil = self.search([
+ ('user_input_id', '=', user_input_id),
+ ('survey_id', '=', question.survey_id.id),
+ ('question_id', '=', question.id)
+ ])
+ if old_uil:
+ old_uil.write(vals)
+ else:
+ old_uil.create(vals)
+ return True
+
+ @api.model
+ def save_line_numerical_box(self, user_input_id, question, post, answer_tag):
+ vals = {
+ 'user_input_id': user_input_id,
+ 'question_id': question.id,
+ 'survey_id': question.survey_id.id,
+ 'skipped': False
+ }
+ if answer_tag in post and post[answer_tag].strip():
+ vals.update({'answer_type': 'number', 'value_number': float(post[answer_tag])})
+ else:
+ vals.update({'answer_type': None, 'skipped': True})
+ old_uil = self.search([
+ ('user_input_id', '=', user_input_id),
+ ('survey_id', '=', question.survey_id.id),
+ ('question_id', '=', question.id)
+ ])
+ if old_uil:
+ old_uil.write(vals)
+ else:
+ old_uil.create(vals)
+ return True
+
+ @api.model
+ def save_line_date(self, user_input_id, question, post, answer_tag):
+ vals = {
+ 'user_input_id': user_input_id,
+ 'question_id': question.id,
+ 'survey_id': question.survey_id.id,
+ 'skipped': False
+ }
+ if answer_tag in post and post[answer_tag].strip():
+ vals.update({'answer_type': 'date', 'value_date': post[answer_tag]})
+ else:
+ vals.update({'answer_type': None, 'skipped': True})
+ old_uil = self.search([
+ ('user_input_id', '=', user_input_id),
+ ('survey_id', '=', question.survey_id.id),
+ ('question_id', '=', question.id)
+ ])
+ if old_uil:
+ old_uil.write(vals)
+ else:
+ old_uil.create(vals)
+ return True
+
+ @api.model
+ def save_line_simple_choice(self, user_input_id, question, post, answer_tag):
+ vals = {
+ 'user_input_id': user_input_id,
+ 'question_id': question.id,
+ 'survey_id': question.survey_id.id,
+ 'skipped': False
+ }
+ old_uil = self.search([
+ ('user_input_id', '=', user_input_id),
+ ('survey_id', '=', question.survey_id.id),
+ ('question_id', '=', question.id)
+ ])
+ old_uil.sudo().unlink()
+
+ if answer_tag in post and post[answer_tag].strip():
+ vals.update({'answer_type': 'suggestion', 'value_suggested': post[answer_tag]})
+ else:
+ vals.update({'answer_type': None, 'skipped': True})
+
+ # '-1' indicates 'comment count as an answer' so do not need to record it
+ if post.get(answer_tag) and post.get(answer_tag) != '-1':
+ self.create(vals)
+
+ comment_answer = post.pop(("%s_%s" % (answer_tag, 'comment')), '').strip()
+ if comment_answer:
+ vals.update({'answer_type': 'text', 'value_text': comment_answer, 'skipped': False, 'value_suggested': False})
+ self.create(vals)
+
+ return True
+
+ @api.model
+ def save_line_multiple_choice(self, user_input_id, question, post, answer_tag):
+ vals = {
+ 'user_input_id': user_input_id,
+ 'question_id': question.id,
+ 'survey_id': question.survey_id.id,
+ 'skipped': False
+ }
+ old_uil = self.search([
+ ('user_input_id', '=', user_input_id),
+ ('survey_id', '=', question.survey_id.id),
+ ('question_id', '=', question.id)
+ ])
+ old_uil.sudo().unlink()
+
+ ca_dict = dict_keys_startswith(post, answer_tag + '_')
+ comment_answer = ca_dict.pop(("%s_%s" % (answer_tag, 'comment')), '').strip()
+ if len(ca_dict) > 0:
+ for key in ca_dict:
+ # '-1' indicates 'comment count as an answer' so do not need to record it
+ if key != ('%s_%s' % (answer_tag, '-1')):
+ vals.update({'answer_type': 'suggestion', 'value_suggested': ca_dict[key]})
+ self.create(vals)
+ if comment_answer:
+ vals.update({'answer_type': 'text', 'value_text': comment_answer, 'value_suggested': False})
+ self.create(vals)
+ if not ca_dict and not comment_answer:
+ vals.update({'answer_type': None, 'skipped': True})
+ self.create(vals)
+ return True
+
+ @api.model
+ def save_line_matrix(self, user_input_id, question, post, answer_tag):
+ vals = {
+ 'user_input_id': user_input_id,
+ 'question_id': question.id,
+ 'survey_id': question.survey_id.id,
+ 'skipped': False
+ }
+ old_uil = self.search([
+ ('user_input_id', '=', user_input_id),
+ ('survey_id', '=', question.survey_id.id),
+ ('question_id', '=', question.id)
+ ])
+ old_uil.sudo().unlink()
+
+ no_answers = True
+ ca_dict = dict_keys_startswith(post, answer_tag + '_')
+
+ comment_answer = ca_dict.pop(("%s_%s" % (answer_tag, 'comment')), '').strip()
+ if comment_answer:
+ vals.update({'answer_type': 'text', 'value_text': comment_answer})
+ self.create(vals)
+ no_answers = False
+
+ if question.matrix_subtype == 'simple':
+ for row in question.labels_ids_2:
+ a_tag = "%s_%s" % (answer_tag, row.id)
+ if a_tag in ca_dict:
+ no_answers = False
+ vals.update({'answer_type': 'suggestion', 'value_suggested': ca_dict[a_tag], 'value_suggested_row': row.id})
+ self.create(vals)
+
+ elif question.matrix_subtype == 'multiple':
+ for col in question.labels_ids:
+ for row in question.labels_ids_2:
+ a_tag = "%s_%s_%s" % (answer_tag, row.id, col.id)
+ if a_tag in ca_dict:
+ no_answers = False
+ vals.update({'answer_type': 'suggestion', 'value_suggested': col.id, 'value_suggested_row': row.id})
+ self.create(vals)
+ if no_answers:
+ vals.update({'answer_type': None, 'skipped': True})
+ self.create(vals)
+ return True
diff --git a/addons/survey/static/src/js/survey.js b/addons/survey/static/src/js/survey.js
index 145005790ec..73a0ccb73e5 100644
--- a/addons/survey/static/src/js/survey.js
+++ b/addons/survey/static/src/js/survey.js
@@ -20,8 +20,6 @@ if(!the_form.length) {
return $.Deferred().reject("DOM doesn't contain '.js_surveyform'");
}
- console.debug("[survey] Custom JS for survey is loading...");
-
var prefill_controller = the_form.attr("data-prefill");
var submit_controller = the_form.attr("data-submit");
var scores_controller = the_form.attr("data-scores");
@@ -167,12 +165,6 @@ if(!the_form.length) {
}
});
- // // Handles the event when a question is focused out
- // $('.js_question-wrapper').focusout(
- // function(){
- // console.debug("[survey] Focus lost on question " + $(this).attr("id"));
- // });
-
function load_locale(){
var url = "/web/webclient/locale/" + context.get().lang || 'en_US';
return ajax.loadJS(url);
@@ -208,6 +200,4 @@ if(!the_form.length) {
display_scores();
}
- console.debug("[survey] Custom JS for survey loaded!");
-
});
diff --git a/addons/survey/static/src/js/survey_result.js b/addons/survey/static/src/js/survey_result.js
index 6e2351ae39a..1cc571ed78c 100644
--- a/addons/survey/static/src/js/survey_result.js
+++ b/addons/survey/static/src/js/survey_result.js
@@ -7,8 +7,6 @@ if(!$('.js_surveyresult').length) {
return $.Deferred().reject("DOM doesn't contain '.js_surveyresult'");
}
- console.debug("[survey] Survey Result JS is loading...");
-
//Script For Pagination
var survey_pagination = $('.pagination');
$.each(survey_pagination, function(index, pagination){
@@ -164,6 +162,4 @@ if(!$('.js_surveyresult').length) {
}
});
- console.debug("[survey] Survey Result JS loaded!");
-
});
diff --git a/addons/survey/tests/test_survey.py b/addons/survey/tests/test_survey.py
index 03c5bbbc5f4..2b6d46b14ff 100644
--- a/addons/survey/tests/test_survey.py
+++ b/addons/survey/tests/test_survey.py
@@ -43,8 +43,8 @@ class TestSurvey(TransactionCase):
self.assertEqual(question.question, "Q0", msg="Title of the Question is somehow modified.")
def test_01_question_type_validation_save_line_function(self):
- for (question_type, text) in self.env['survey.question']._fields['type'].selection:
- # Each question ype must have validation function.
+ for (question_type, text) in self.env['survey.question']._fields['question_type'].selection:
+ # Each question type must have validation function.
self.assertTrue(hasattr(self.env['survey.question'], 'validate_' + question_type), msg="Question must have a validation method in\
the form of 'validate_' followed by the name of the type.")
@@ -53,24 +53,24 @@ class TestSurvey(TransactionCase):
the form of 'save_line_' followed by the name of the type.")
def test_02_question_answer_required(self):
- for (question_type, text) in self.env['survey.question']._fields['type'].selection:
+ for (question_type, text) in self.env['survey.question']._fields['question_type'].selection:
# Blank value of field is not accepted for mandatory questions.
if question_type == 'multiple_choice':
question = self.env['survey.question'].sudo(self.survey_manager).create({
- 'page_id': self.page1.id, 'question': 'Q0', 'type': 'multiple_choice',
+ 'page_id': self.page1.id, 'question': 'Q0', 'question_type': 'multiple_choice',
'constr_mandatory': True, 'constr_error_msg': 'Error',
'labels_ids': [(0, 0, {'value': "MChoice0", "quizz_mark": 0}), (0, 0, {'value': "MChoice1", "quizz_mark": 0})]})
elif question_type == 'matrix':
question = self.env['survey.question'].sudo(self.survey_manager).create({
- 'page_id': self.page1.id, 'question': 'Q0', 'type': 'matrix', 'matrix_subtype': 'simple',
+ 'page_id': self.page1.id, 'question': 'Q0', 'question_type': 'matrix', 'matrix_subtype': 'simple',
'constr_mandatory': True, 'constr_error_msg': 'Error',
'labels_ids': [(0, 0, {'value': "Column0", "quizz_mark": 0}), (0, 0, {'value': "Column1", "quizz_mark": 0})],
'labels_ids_2': [(0, 0, {'value': "Row0", "quizz_mark": 0}), (0, 0, {'value': "Row1", "quizz_mark": 0})]})
else:
question = self.env['survey.question'].sudo(self.survey_manager).create({
- 'page_id': self.page1.id, 'question': 'Q0', 'type': question_type, 'constr_mandatory': True, 'constr_error_msg': 'Error'})
+ 'page_id': self.page1.id, 'question': 'Q0', 'question_type': question_type, 'constr_mandatory': True, 'constr_error_msg': 'Error'})
answer_tag = '%s_%s_%s' % (self.survey1.id, self.page1.id, question.id)
self.assertDictEqual({answer_tag: "Error"}, question.validate_question({answer_tag: ''}, answer_tag),
msg=("Validation function for type %s is unable to generate error if it is mandatory and answer is blank." % question_type))
@@ -78,9 +78,9 @@ class TestSurvey(TransactionCase):
def test_03_question_textbox(self):
questions = [
self.env['survey.question'].sudo(self.survey_manager).create({
- 'page_id': self.page1.id, 'question': 'Q0', 'type': 'textbox', 'validation_email': True}),
+ 'page_id': self.page1.id, 'question': 'Q0', 'question_type': 'textbox', 'validation_email': True}),
self.env['survey.question'].sudo(self.survey_manager).create({
- 'page_id': self.page1.id, 'question': 'Q1', 'type': 'textbox', 'validation_required': True,
+ 'page_id': self.page1.id, 'question': 'Q1', 'question_type': 'textbox', 'validation_required': True,
'validation_length_min': 2, 'validation_length_max': 8, 'validation_error_msg': "Error"})]
results = [('test @ testcom', _('This answer must be an email address')), ('t', 'Error')]
@@ -91,7 +91,7 @@ class TestSurvey(TransactionCase):
def test_04_question_numerical_box(self):
question = self.env['survey.question'].sudo(self.survey_manager).create({
- 'page_id': self.page1.id, 'question': 'Q0', 'type': 'numerical_box', 'validation_required': True,
+ 'page_id': self.page1.id, 'question': 'Q0', 'question_type': 'numerical_box', 'validation_required': True,
'validation_min_float_value': 2.1, 'validation_max_float_value': 3.0, 'validation_error_msg': "Error"})
answer_tag = '%s_%s_%s' % (self.survey1.id, self.page1.id, question.id)
results = [('aaa', _('This is not a number')), ('4.5', 'Error'), ('0.1', 'Error')]
@@ -101,7 +101,7 @@ class TestSurvey(TransactionCase):
def test_05_question_date(self):
question = self.env['survey.question'].sudo(self.survey_manager).create({
- 'page_id': self.page1.id, 'question': 'Q0', 'type': 'date', 'validation_required': True,
+ 'page_id': self.page1.id, 'question': 'Q0', 'question_type': 'date', 'validation_required': True,
'validation_min_date': '2015-03-20', 'validation_max_date': '2015-03-25', 'validation_error_msg': "Error"})
answer_tag = '%s_%s_%s' % (self.survey1.id, self.page1.id, question.id)
results = [('2015-55-10', _('This is not a date')), ('2015-03-19', 'Error'), ('2015-03-26', 'Error')]
@@ -113,7 +113,7 @@ class TestSurvey(TransactionCase):
# Case-1: Executing action with correct data.
correct_survey = self.env['survey.survey'].sudo(self.survey_manager).create({
'title': "S0", 'stage_id': self.env['survey.stage'].search([('sequence', '=', 1)]).id,
- 'page_ids': [(0, 0, {'title': "P0", 'question_ids': [(0, 0, {'question': "Q0", 'type': 'free_text'})]})]})
+ 'page_ids': [(0, 0, {'title': "P0", 'question_ids': [(0, 0, {'question': "Q0", 'question_type': 'free_text'})]})]})
action = correct_survey.action_send_survey()
template = self.env.ref('survey.email_template_survey', raise_if_not_found=False)
@@ -143,7 +143,7 @@ class TestSurvey(TransactionCase):
'title': "Test survey"}),
self.env['survey.survey'].sudo(self.survey_manager).create({ # Closed Survey.
'title': "S0", 'stage_id': self.env['survey.stage'].search([('closed', '=', True)]).id, # Getting Closed stage id.
- 'page_ids': [(0, 0, {'title': "P0", 'question_ids': [(0, 0, {'question': "Q0", 'type': 'free_text'})]})]})]
+ 'page_ids': [(0, 0, {'title': "P0", 'question_ids': [(0, 0, {'question': "Q0", 'question_type': 'free_text'})]})]})]
for survey in surveys:
self.assertRaises(UserError, survey.action_send_survey)
@@ -215,7 +215,7 @@ class TestSurvey(TransactionCase):
def test_10_survey_result_simple_multiple_choice(self):
question = self.env['survey.question'].sudo(self.survey_manager).create({
- 'page_id': self.page1.id, 'question': 'Q0', 'type': 'simple_choice',
+ 'page_id': self.page1.id, 'question': 'Q0', 'question_type': 'simple_choice',
'labels_ids': [(0, 0, {'value': "Choice0", 'quizz_mark': 0}), (0, 0, {'value': "Choice1", 'quizz_mark': 0})]})
for i in range(3):
self.env['survey.user_input'].sudo(self.user_public).create({'survey_id': self.survey1.id, 'user_input_line_ids': [(0, 0, {
@@ -229,7 +229,7 @@ class TestSurvey(TransactionCase):
def test_11_survey_result_matrix(self):
question = self.env['survey.question'].sudo(self.survey_manager).create({
- 'page_id': self.page1.id, 'question': 'Q0', 'type': 'matrix', 'matrix_subtype': 'simple',
+ 'page_id': self.page1.id, 'question': 'Q0', 'question_type': 'matrix', 'matrix_subtype': 'simple',
'labels_ids': [(0, 0, {'value': "Column0", "quizz_mark": 0}), (0, 0, {'value': "Column1", "quizz_mark": 0})],
'labels_ids_2': [(0, 0, {'value': "Row0", "quizz_mark": 0}), (0, 0, {'value': "Row1", "quizz_mark": 0})]})
for i in range(3):
@@ -243,7 +243,7 @@ class TestSurvey(TransactionCase):
self.assertEqual(self.env['survey.survey'].prepare_result(question)['result'], res, msg="Statistics of matrix type questions are different from expectations")
def test_12_survey_result_numeric_box(self):
- question = self.env['survey.question'].sudo(self.survey_manager).create({'page_id': self.page1.id, 'question': 'Q0', 'type': 'numerical_box'})
+ question = self.env['survey.question'].sudo(self.survey_manager).create({'page_id': self.page1.id, 'question': 'Q0', 'question_type': 'numerical_box'})
num = [float(n) for n in random.sample(range(1, 100), 3)]
nsum = sum(num)
for i in range(3):
@@ -257,7 +257,7 @@ class TestSurvey(TransactionCase):
self.assertEqual(result[key], exresult[key], msg="Statistics of numeric box type questions are different from expectations")
def test_13_survey_actions(self):
- self.env['survey.question'].sudo(self.survey_manager).create({'page_id': self.page1.id, 'question': 'Q0', 'type': 'numerical_box'})
+ self.env['survey.question'].sudo(self.survey_manager).create({'page_id': self.page1.id, 'question': 'Q0', 'question_type': 'numerical_box'})
actions = {
'start': {'method': 'public', 'token': '/test', 'text': 'Start'},
diff --git a/addons/survey/views/assets.xml b/addons/survey/views/assets.xml
new file mode 100644
index 00000000000..099877c34d6
--- /dev/null
+++ b/addons/survey/views/assets.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+<data>
+ <!-- survey assets -->
+ <template id="survey_assets" name="Survey Results assets">
+ <script src="/web/static/lib/nvd3/d3.v3.js"></script>
+ <script src="/web/static/lib/nvd3/nv.d3.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/services/session.js"></script>
+ <script type="text/javascript" src="/survey/static/src/js/survey_result.js" />
+ <script type="text/javascript" src="/survey/static/src/js/survey.js" />
+ <script type="text/javascript" src="/survey/static/src/js/tour_test_survey.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/fields/field_utils.js"></script>
+
+ <t t-call="web._assets_frontend_helpers"/>
+ <link href="/web/static/lib/nvd3/nv.d3.css" rel="stylesheet" type="text/css"></link>
+ <t t-call="web._assets_bootstrap"/>
+ <link rel="stylesheet" type="text/less" href="/web/static/src/scss/fonts.scss" />
+ <!-- Some helper classes (like mt- mb-) are required for survey templates -->
+ <link rel="stylesheet" type="text/less" href="/web_editor/static/src/scss/web_editor.common.scss"/>
+ <link href="/survey/static/src/css/survey_print.css" rel="stylesheet" type="text/css"/>
+ <link href="/survey/static/src/css/survey_result.css" rel="stylesheet" type="text/css"></link>
+ </template>
+
+ <template id="assets_backend" name="survey assets" inherit_id="web.assets_backend">
+ <xpath expr="." position="inside">
+ <link rel="stylesheet" href="/survey/static/src/css/survey_result.css"/>
+ </xpath>
+ </template>
+</data>
+</odoo> \ No newline at end of file
diff --git a/addons/survey/views/survey_menus.xml b/addons/survey/views/survey_menus.xml
new file mode 100644
index 00000000000..5ec28117b59
--- /dev/null
+++ b/addons/survey/views/survey_menus.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+<data>
+ <!-- Main menu -->
+ <menuitem name="Surveys" id="menu_surveys" sequence="70" groups="group_survey_user" web_icon="survey,static/description/icon.png"/>
+
+ <!-- Parent menus -->
+ <menuitem name="Configuration" id="menu_surveys_configuration" parent="menu_surveys" groups="base.group_no_one" sequence="100"/>
+</data>
+</odoo> \ No newline at end of file
diff --git a/addons/survey/views/survey_page_views.xml b/addons/survey/views/survey_page_views.xml
new file mode 100644
index 00000000000..2f1ae7411fb
--- /dev/null
+++ b/addons/survey/views/survey_page_views.xml
@@ -0,0 +1,71 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+<data>
+ <!-- PAGES -->
+ <record model="ir.ui.view" id="survey_page_form">
+ <field name="name">Form view for survey page</field>
+ <field name="model">survey.page</field>
+ <field name="arch" type="xml">
+ <form string="Survey Page" create="false">
+ <sheet>
+ <div class="oe_title" style="width: 100%;">
+ <label for="title" class="oe_edit_only"/>
+ <h1><field name="title" placeholder="Page Title"/></h1>
+ </div>
+ <field name="question_ids" nolabel="1" mode="tree" context="{'default_page_id': active_id}">
+ <tree>
+ <field name="sequence" widget="handle"/>
+ <field name="question"/>
+ <field name="question_type"/>
+ <field name="constr_mandatory"/>
+ </tree>
+ </field>
+ </sheet>
+ </form>
+ </field>
+ </record>
+ <record model="ir.ui.view" id="survey_page_tree">
+ <field name="name">Tree view for survey page</field>
+ <field name="model">survey.page</field>
+ <field name="arch" type="xml">
+ <tree string="Survey Page" create="false">
+ <field name="sequence" widget="handle"/>
+ <field name="title"/>
+ <field name="survey_id"/>
+ <field name="question_ids" string="#Questions"/>
+ </tree>
+ </field>
+ </record>
+ <record model="ir.ui.view" id="survey_page_search" >
+ <field name="name">Search view for survey page</field>
+ <field name="model">survey.page</field>
+ <field name="arch" type="xml">
+ <search string="Search Page">
+ <field name="title" string="Page"/>
+ <field name="survey_id" string="Survey"/>
+ <group expand="0" string="Group By">
+ <filter name="group_by_survey" string="Survey" domain="[]" context="{'group_by':'survey_id'}"/>
+ </group>
+ </search>
+ </field>
+ </record>
+
+ <record model="ir.actions.act_window" id="action_survey_page_form">
+ <field name="name">Pages</field>
+ <field name="res_model">survey.page</field>
+ <field name="view_type">form</field>
+ <field name="view_mode">tree,form</field>
+ <field name="search_view_id" ref="survey_page_search"/>
+ <field name="context">{'search_default_group_by_survey': True}</field>
+ <field name="help" type="html">
+ <p class="o_view_nocontent_empty_folder">
+ No pages found
+ </p>
+ </field>
+ </record>
+
+ <act_window context="{'search_default_page_id': active_id, 'default_page_id': active_id}" id="act_survey_page_question" name="Questions" res_model="survey.question" src_model="survey.page"/>
+
+ <menuitem name="Pages" id="menu_survey_page_form1" action="action_survey_page_form" parent="menu_surveys_configuration" sequence="1"/>
+</data>
+</odoo> \ No newline at end of file
diff --git a/addons/survey/views/survey_question_views.xml b/addons/survey/views/survey_question_views.xml
new file mode 100644
index 00000000000..b66fc391558
--- /dev/null
+++ b/addons/survey/views/survey_question_views.xml
@@ -0,0 +1,228 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+<data>
+ <!-- QUESTIONS -->
+ <record model="ir.ui.view" id="survey_question_form">
+ <field name="name">Form view for survey question</field>
+ <field name="model">survey.question</field>
+ <field name="arch" type="xml">
+ <form string="Survey Question" create="false">
+ <sheet>
+ <div class="oe_title" style="width: 100%;">
+ <label for="question" string="Question name" class="oe_edit_only"/>
+ <h1><field name="question" colspan="4" placeholder="Question Name"/></h1>
+ <separator />
+ </div>
+ <group>
+ <group>
+ <field name="question_type" widget="radio" />
+ </group>
+ <group>
+ <div class="col-lg-6 offset-lg-3 o_preview_questions">
+ <!-- Multiple Lines Text Zone -->
+ <div attrs="{'invisible': [('question_type', '!=', 'free_text')]}">
+ <i class="fa fa-align-justify fa-4x" role="img" aria-label="Multiple lines" title="Multiple Lines"/>
+ </div>
+ <!-- Single Line Text Zone -->
+ <div attrs="{'invisible': [('question_type', '!=', 'textbox')]}">
+ <i class="fa fa-minus fa-4x" role="img" aria-label="Single Line" title="Single Line"/>
+ </div>
+ <!-- Numerical Value -->
+ <div attrs="{'invisible': [('question_type', '!=', 'numerical_box')]}">
+ <i class="fa fa-2x" role="img" aria-label="Numeric" title="Numeric">123..</i>
+ </div>
+ <!-- Date and Time -->
+ <div attrs="{'invisible': [('question_type', '!=', 'date')]}">
+ <p class="o_datetime">YYYY-MM-DD
+ <i class="fa fa-calendar fa-2x" role="img" aria-label="Calendar" title="Calendar"/>
+ </p>
+ </div>
+ <!-- Multiple choice: only one answer -->
+ <div attrs="{'invisible': [('question_type', '!=', 'simple_choice')]}" role="img" aria-label="Multiple choice with one answer" title="Multiple choice with one answer">
+ <div class="row"><i class="fa fa-circle-o fa-lg"/> answer</div>
+ <div class="row"><i class="fa fa-dot-circle-o fa-lg"/> answer</div>
+ <div class="row"><i class="fa fa-circle-o fa-lg"/> answer</div>
+ </div>
+ <!-- Multiple choice: multiple answers allowed -->
+ <div attrs="{'invisible': [('question_type', '!=', 'multiple_choice')]}" role="img" aria-label="Multiple choice with multiple answers" title="Multiple choice with multiple answers">
+ <div class="row"><i class="fa fa-square-o fa-lg"/> answer</div>
+ <div class="row"><i class="fa fa-check-square-o fa-lg"/> answer</div>
+ <div class="row"><i class="fa fa-square-o fa-lg"/> answer</div>
+ </div>
+ <!-- Matrix -->
+ <div attrs="{'invisible': [('question_type', '!=', 'matrix')]}">
+ <div class="row o_matrix_head">
+ <div class="col-lg-3"></div>
+ <div class="col-lg-3">ans</div>
+ <div class="col-lg-3">ans</div>
+ <div class="col-lg-3">ans</div>
+ </div>
+ <div class="row o_matrix_row">
+ <div class="col-lg-3">Row1</div>
+ <div class="col-lg-3"><i class="fa fa-circle-o fa-lg" role="img" aria-label="Not checked" title="Not checked"/></div>
+ <div class="col-lg-3"><i class="fa fa-dot-circle-o fa-lg" role="img" aria-label="Checked" title="Checked"/></div>
+ <div class="col-lg-3"><i class="fa fa-circle-o fa-lg" role="img" aria-label="Not checked" title="Not checked"/></div>
+ </div>
+ <div class="row o_matrix_row">
+ <div class="col-lg-3">Row2</div>
+ <div class="col-lg-3"><i class="fa fa-circle-o fa-lg" role="img" aria-label="Not checked" title="Not checked"/></div>
+ <div class="col-lg-3"><i class="fa fa-circle-o fa-lg" role="img" aria-label="Not checked" title="Not checked"/></div>
+ <div class="col-lg-3"><i class="fa fa-dot-circle-o fa-lg" role="img" aria-label="Checked" title="Checked"/></div>
+ </div>
+ <div class="row o_matrix_row">
+ <div class="col-lg-3">Row3</div>
+ <div class="col-lg-3"><i class="fa fa-dot-circle-o fa-lg" role="img" aria-label="Checked" title="Checked"/></div>
+ <div class="col-lg-3"><i class="fa fa-circle-o fa-lg" role="img" aria-label="Not checked" title="Not checked"/></div>
+ <div class="col-lg-3"><i class="fa fa-circle-o fa-lg" role="img" aria-label="Not checked" title="Not checked"/></div>
+ </div>
+ </div>
+ </div>
+ </group>
+ </group>
+ <notebook>
+ <page string="Answers">
+ <field name="validation_email" attrs="{'invisible': [('question_type', '!=', 'textbox')]}"/>
+ <label for="validation_email" attrs="{'invisible': [('question_type', '!=', 'textbox')]}"/>
+ <separator />
+ <field name="labels_ids" string="Type of answers" context="{'default_question_id': active_id}" attrs="{'invisible': [('question_type', 'not in', ['simple_choice', 'multiple_choice', 'matrix'])]}">
+ <tree editable="bottom">
+ <field name="sequence" widget="handle"/>
+ <field name="value" string="Choices"/>
+ <field name="quizz_mark" groups="base.group_no_one"/>
+ </tree>
+ </field>
+ <separator />
+ <label for="labels_ids_2" attrs="{'invisible': [('question_type', '!=', 'matrix')]}" />
+ <field name="labels_ids_2" context="{'default_question_id_2': active_id}" attrs="{'invisible': [('question_type', '!=', 'matrix')]}">
+ <tree editable="bottom">
+ <field name="sequence" widget="handle"/>
+ <field name="value" string="Rows"/>
+ </tree>
+ </field>
+ </page>
+ <page string="Options">
+ <group string="Constraints">
+ <group colspan="2" col="4">
+ <field name="constr_mandatory" string="Mandatory Answer"/>
+ <field name="constr_error_msg" attrs="{'invisible': [('constr_mandatory', '=', False)]}"/>
+ </group>
+ <div colspan="2" attrs="{'invisible': [('question_type', 'not in', ['textbox', 'numerical_box', 'date'])]}">
+ <group>
+ <field name="validation_required" attrs="{'invisible': [('validation_email', '=', True), ('question_type', '=', 'textbox')]}"/>
+ </group>
+ <group col="4" attrs="{'invisible': [('validation_required', '=', False)]}">
+ <field name="validation_length_min" attrs="{'invisible': [('question_type', '!=', 'textbox')]}"/>
+ <field name="validation_length_max" attrs="{'invisible': [('question_type', '!=', 'textbox')]}"/>
+ <field name="validation_min_float_value" attrs="{'invisible': [('question_type', '!=', 'numerical_box')]}"/>
+ <field name="validation_max_float_value" attrs="{'invisible': [('question_type', '!=', 'numerical_box')]}"/>
+ <field name="validation_min_date" attrs="{'invisible': [('question_type', '!=', 'date')]}"/>
+ <field name="validation_max_date" attrs="{'invisible': [('question_type', '!=', 'date')]}"/>
+ <field name="validation_error_msg" colspan="4"/>
+ </group>
+ </div>
+ <group>
+ <field name="matrix_subtype" attrs="{'invisible':[('question_type','not in',['matrix'])]}"/>
+ </group>
+ </group>
+ <group string="Display mode" attrs="{'invisible':[('question_type','not in',['simple_choice', 'multiple_choice'])]}">
+ <field name="display_mode" string="Format" attrs="{'invisible':[('question_type','not in',['simple_choice'])],'required':[('question_type','=','simple_choice')]}"/>
+ <field name="column_nb" string="Number of columns" attrs="{'invisible':[('display_mode','=','dropdown'), ('question_type','=','simple_choice')]}"/>
+ </group>
+ <group string="Allow Comments" attrs="{'invisible':[('question_type','not in',['simple_choice','multiple_choice', 'matrix'])]}">
+ <field name='comments_allowed' />
+ <field name='comments_message' attrs="{'invisible': [('comments_allowed', '=', False)]}"/>
+ <field name='comment_count_as_answer' attrs="{'invisible': ['|', ('comments_allowed', '=', False), ('question_type', 'in', ['matrix'])]}" />
+ </group>
+ </page>
+ </notebook>
+ </sheet>
+ </form>
+ </field>
+ </record>
+ <record model="ir.ui.view" id="survey_question_tree">
+ <field name="name">Tree view for survey question</field>
+ <field name="model">survey.question</field>
+ <field name="arch" type="xml">
+ <tree string="Survey Question" create="false">
+ <field name="sequence" widget="handle"/>
+ <field name="question"/>
+ <field name="page_id"/>
+ <field name="survey_id"/>
+ <field name="question_type"/>
+ </tree>
+ </field>
+ </record>
+ <record model="ir.ui.view" id="survey_question_search">
+ <field name="name">Search view for survey question</field>
+ <field name="model">survey.question</field>
+ <field name="arch" type="xml">
+ <search string="Search Question">
+ <field name="question" string="Question"/>
+ <field name="survey_id" string="Survey"/>
+ <field name="page_id" string="Page"/>
+ <field name="question_type" string="Type"/>
+ <group expand="1" string="Group By">
+ <filter name="group_by_page" string="Page" domain="[]" context="{'group_by':'page_id'}"/>
+ <filter name="group_by_type" string="Type" domain="[]" context="{'group_by':'question_type'}"/>
+ </group>
+ </search>
+ </field>
+ </record>
+
+ <record model="ir.actions.act_window" id="action_survey_question_form">
+ <field name="name">Questions</field>
+ <field name="res_model">survey.question</field>
+ <field name="view_type">form</field>
+ <field name="view_mode">tree,form</field>
+ <field name="search_view_id" ref="survey_question_search"/>
+ <field name="context">{'search_default_group_by_page': True}</field>
+ <field name="help" type="html">
+ <p class="o_view_nocontent_empty_folder">
+ No questions found
+ </p>
+ </field>
+ </record>
+
+ <!-- LABELS -->
+ <record model="ir.ui.view" id="survey_label_tree">
+ <field name="name">survey_label_tree</field>
+ <field name="model">survey.label</field>
+ <field name="arch" type="xml">
+ <tree string="Survey Label" create="false">
+ <field name="sequence" widget="handle"/>
+ <field name="question_id"/>
+ <field name="question_id_2"/>
+ <field name="value"/>
+ <field name="quizz_mark" groups="base.group_no_one"/>
+ </tree>
+ </field>
+ </record>
+ <record id="survey_label_search" model="ir.ui.view">
+ <field name="name">survey_label_search</field>
+ <field name="model">survey.label</field>
+ <field name="arch" type="xml">
+ <search string="Search Label">
+ <field name="question_id" string="Question"/>
+ <filter name="group_by_question" string="Question" domain="[]" context="{'group_by':'question_id'}"/>
+ </search>
+ </field>
+ </record>
+
+ <record model="ir.actions.act_window" id="action_survey_label_form">
+ <field name="name">Labels</field>
+ <field name="res_model">survey.label</field>
+ <field name="view_type">form</field>
+ <field name="view_mode">tree,form</field>
+ <field name="search_view_id" ref="survey_label_search"/>
+ <field name="context">{'search_default_group_by_question': True}</field>
+ <field name="help" type="html">
+ <p class="o_view_nocontent_empty_folder">
+ No survey labels found
+ </p>
+ </field>
+ </record>
+
+ <menuitem name="Questions" id="menu_survey_question_form1" action="action_survey_question_form" parent="menu_surveys_configuration" sequence="2"/>
+ <menuitem name="Labels" id="menu_survey_label_form1" action="action_survey_label_form" parent="menu_surveys_configuration" sequence="3"/>
+</data>
+</odoo> \ No newline at end of file
diff --git a/addons/survey/views/survey_result.xml b/addons/survey/views/survey_result.xml
deleted file mode 100644
index 69190c195cc..00000000000
--- a/addons/survey/views/survey_result.xml
+++ /dev/null
@@ -1,346 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<odoo>
-<data>
- <template id="result" name="Survey Result">
- <t t-call="survey.layout">
- <div class="oe_structure" id="oe_structure_survey_result_1"/>
- <div class="container js_surveyresult">
- <t t-call="survey.back" />
- <div class="jumbotron mt32">
- <h1><span t-field="survey.title" /> <span style="font-size:1.5em;" class="fa fa-bar-chart-o float-right " role="img" aria-label="Chart" title="Chart"/></h1>
- <div t-field="survey.description" class="oe_no_empty" />
- </div>
- <div class="card d-print-none">
- <div class="card-header"><span class="fa fa-filter"></span> Filters <span t-if="filter_display_data" class="float-right text-primary clear_survey_filter"><i class="fa fa-times"></i> Clear All Filters</span></div>
- <div class="card-body">
- <span t-if="filter_finish == True">
- <span class="badge badge-secondary only_left_radius filter-all">All surveys</span><span class="badge badge-primary only_right_radius filter-finished">Finished surveys</span>
- </span>
- <span t-if="filter_finish == False">
- <span class="badge badge-primary only_left_radius filter-all">All surveys</span><span class="badge badge-secondary only_right_radius filter-finished">Finished surveys</span>
- </span>
- <span t-foreach="filter_display_data" t-as="filter_data">
- <span class="badge badge-primary only_left_radius"><i class="fa fa-filter" role="img" aria-label="Filter" title="Filter"></i></span><span class="badge badge-primary no_radius" t-esc="filter_data['question_text']"></span><span class="badge badge-success only_right_radius" t-esc="' > '.join(filter_data['labels'])"></span>
- </span>
- </div>
- </div>
- <div t-foreach="survey_dict['page_ids']" t-as='page_ids'>
- <t t-set="page" t-value="page_ids['page']"/>
- <h1 class="mt16" t-field='page.title'></h1>
- <div t-field="page.description" class="oe_no_empty" />
- <hr/>
- <div t-foreach="page_ids['question_ids']" t-as='question_ids' class="mt16">
- <t t-set="input_summary" t-value="question_ids['input_summary']"/>
- <t t-set="question" t-value="question_ids['question']"/>
- <t t-set="graph_data" t-value="question_ids['graph_data']"/>
- <t t-set="prepare_result" t-value="question_ids['prepare_result']"/>
- <h4>
- <b>Question </b>
- <span t-field='question.question'></span>
- <t t-if="question.type == 'matrix'">
- <small><span class="badge badge-secondary">Matrix: <span t-field='question.matrix_subtype'/></span></small>
- </t>
- <t t-if="question.type in ['simple_choice', 'multiple_choice']">
- <small><span t-field='question.type' class="badge badge-secondary"></span></small>
- </t>
- <span class="float-right">
- <span class="badge badge-success"><span t-esc="input_summary['answered']"></span> Answered</span>
- <span class="badge badge-danger"><span t-esc="input_summary['skipped']"></span> Skipped</span>
- </span>
- </h4>
- <t t-if="input_summary['answered'] != 0">
- <t t-if="question.description">
- <div class="text-muted oe_no_empty" t-field="question.description" />
- </t>
- <t t-if="question.type in ['textbox', 'free_text', 'date']">
- <t t-call="survey.result_text"></t>
- </t>
- <t t-if="question.type in ['simple_choice', 'multiple_choice']">
- <t t-call="survey.result_choice"></t>
- </t>
- <t t-if="question.type == 'matrix'">
- <t t-call="survey.result_matrix"></t>
- </t>
- <t t-if="question.type == 'numerical_box'">
- <t t-call="survey.result_number"></t>
- </t>
- </t>
- <t t-if="input_summary['answered'] == 0">
- <h2 style="padding-top:30px;padding-bottom:30px;text-align:center;" class="text-muted">Sorry, No one answered this question.</h2>
- </t>
- </div>
- </div>
- </div>
- <div class="oe_structure" id="oe_structure_survey_result_2"/>
- </t>
- </template>
-
- <!-- Result for free_text,textbox and date -->
- <template id="result_text" name="Text Result">
- <table class="table table-hover table-sm" t-att-id="'table_question_%d' % question.id">
- <thead>
- <tr>
- <th>#</th>
- <th>User Responses</th>
- </tr>
- </thead>
- <tbody>
- <t t-set="text_result" t-value="prepare_result"/>
- <tr t-foreach="text_result" t-as="user_input">
- <td><a t-att-href="'%s/%s' % (user_input.user_input_id.print_url, user_input.user_input_id.token)"><t t-esc="user_input_index + 1"></t></a></td>
- <t t-if="question.type == 'free_text'">
- <td>
- <a t-att-href="'%s/%s' % (user_input.user_input_id.print_url, user_input.user_input_id.token)" t-field="user_input.value_free_text"></a><br/>
- </td>
- </t>
- <t t-if="question.type == 'textbox'">
- <td>
- <a t-att-href="'%s/%s' % (user_input.user_input_id.print_url, user_input.user_input_id.token)" t-field="user_input.value_text"></a><br/>
- </td>
- </t>
- <t t-if="question.type == 'date'">
- <td>
- <a t-att-href="'%s/%s' % (user_input.user_input_id.print_url, user_input.user_input_id.token)" t-field="user_input.value_date"></a><br/>
- </td>
- </t>
- </tr>
- </tbody>
- </table>
- <t t-call="survey.pagination" />
- </template>
-
- <!-- Result for comments -->
- <template id="result_comments" name="Text Result">
- <!-- a 'comments' variable must be set an must contain a list of browse records of user input lines -->
- <table class="table table-hover table-sm" t-att-id="'table_question_%d' % question.id">
- <thead>
- <tr>
- <th>#</th>
- <th>Comment</th>
- </tr>
- </thead>
- <tbody>
- <tr t-foreach="comments" t-as="user_input">
- <td><a t-att-href="'%s/%s' % (user_input.user_input_id.print_url, user_input.user_input_id.token)"><t t-esc="user_input_index + 1"></t></a></td>
- <td>
- <span t-field="user_input.value_text"></span><br/>
- </td>
- </tr>
- </tbody>
- </table>
- </template>
-
-
- <!-- Result for simple_choice and multiple_choice -->
- <template id="result_choice" name="Choice Result">
- <div>
- <!-- Tabs -->
- <ul class="nav nav-tabs d-print-none" role="tablist">
- <li class="nav-item" t-if="question.type != 'simple_choice'">
- <a t-att-href="'#graph_question_%d' % question.id" t-att-aria-controls="'graph_question_%d' % question.id" class="nav-link active" data-toggle="tab" role="tab">
- <i class="fa fa-bar-chart-o"></i> Graph
- </a>
- </li>
- <li class="nav-item" t-if="question.type == 'simple_choice'">
- <a t-att-href="'#graph_question_%d' % question.id" t-att-aria-controls="'graph_question_%d' % question.id" class="nav-link active" data-toggle="tab" role="tab">
- <i class="fa fa-bar-chart-o"></i> Pie Chart
- </a>
- </li>
- <li class="nav-item">
- <a t-att-href="'#data_question_%d' % question.id" t-att-aria-controls="'data_question_%d' % question.id" class="nav-link" data-toggle="tab" role="tab">
- <i class="fa fa-list-alt"></i> Data
- </a>
- </li>
- </ul>
- <div class="tab-content">
- <div role="tabpanel" class="tab-pane active survey_graph" t-if="question.type != 'simple_choice'" t-att-id="'graph_question_%d' % question.id" t-att-data-question_id="question.id" data-graph_type="bar" t-att-graph-data="graph_data">
- <!-- svg element for drawing bar chart -->
- <svg style="height:20em"></svg>
- </div>
- <div role="tabpanel" class="tab-pane active survey_graph" t-if="question.type == 'simple_choice'" t-att-id="'graph_question_%d' % question.id" t-att-data-question_id="question.id" data-graph_type="pie" t-att-graph-data="graph_data">
- <!-- svg element for drawing pie chart -->
- <svg style="height:20em"></svg>
- </div>
- <div role="tabpanel" class="tab-pane" t-att-id="'data_question_%d' % question.id">
- <table class="table table-hover table-sm">
- <thead>
- <tr>
- <th>Answer Choices</th>
- <th>User Responses</th>
- </tr>
- </thead>
- <tbody>
- <tr t-foreach="prepare_result['answers']" t-as="user_input">
- <td>
- <p t-esc="user_input['text']"></p>
- </td>
- <td class="survey_answer">
- <span t-esc="round(user_input['count']*100.0/(input_summary['answered'] or 1),2)"></span> %
- <span t-esc="user_input['count']" class="badge badge-primary">Vote</span>
- <i class="fa fa-filter text-primary invisible survey_filter" t-att-data-question_id="question.id" t-att-data-answer_id="user_input['answer_id']" role="img" aria-label="Filter question" title="Filter question"/>
- </td>
- </tr>
- </tbody>
- </table>
- </div>
- </div>
- <!-- handle comments -->
- <div>
- <t t-set="comments" t-value="prepare_result['comments']" />
- <t t-if="comments">
- <t t-call="survey.result_comments" />
- </t>
- </div>
-
- </div>
- </template>
-
- <!-- Result for matrix -->
- <template id="result_matrix" name="Matrix Result">
- <t t-set="matrix_result" t-value="prepare_result"/>
- <!-- Tabs -->
- <ul class="nav nav-tabs d-print-none" role="tablist">
- <li class="nav-item">
- <a t-att-href="'#graph_question_%d' % question.id" t-att-aria-controls="'graph_question_%d' % question.id" class="nav-link active" data-toggle="tab" role="tab">
- <i class="fa fa-bar-chart"></i>
- Graph
- </a>
- </li>
- <li class="nav-item">
- <a t-att-href="'#data_question_%d' % question.id" t-att-aria-controls="'data_question_%d' % question.id" class="nav-link" data-toggle="tab" role="tab">
- <i class="fa fa-list-alt"></i>
- Data
- </a>
- </li>
- </ul>
- <div class="tab-content">
- <div role="tabpanel" class="tab-pane active with-3d-shadow with-transitions survey_graph" t-att-id="'graph_question_%d' % question.id" t-att-data-question_id= "question.id" data-graph_type= "multi_bar" t-att-graph-data="graph_data">
- <!-- svg element for drawing Multibar chart -->
- <svg style="height:20em"></svg>
- </div>
- <div role="tabpanel" class="tab-pane" t-att-id="'data_question_%d' % question.id">
- <table class="table table-hover table-sm text-right">
- <thead>
- <tr>
- <th></th>
- <th class="text-right" t-foreach="matrix_result['answers']" t-as="answer_id">
- <span t-esc="matrix_result['answers'][answer_id]"></span>
- </th>
- </tr>
- </thead>
- <tbody>
- <tr t-foreach="matrix_result['rows']" t-as="row_id">
- <td>
- <span t-esc="matrix_result['rows'][row_id]"></span>
- </td>
- <td class="survey_answer" t-foreach="matrix_result['answers']" t-as="answer_id">
- <span t-esc="round(matrix_result['result'][(row_id,answer_id)]*100.0/(input_summary['answered'] or 1),2)"></span> %
- <span class="badge badge-primary" t-esc="matrix_result['result'][(row_id,answer_id)]"></span><i class="fa fa-filter text-primary invisible survey_filter" t-att-data-question_id="question.id" t-att-data-row_id="row_id" t-att-data-answer_id="answer_id" role="img" aria-label="Survey filter" title="Survey filter"></i>
- </td>
- </tr>
- </tbody>
- </table>
- </div>
- <!-- handle comments to matrix -->
- <div>
- <t t-set="comments" t-value="matrix_result['comments']" />
- <t t-if="comments">
- <t t-call="survey.result_comments" />
- </t>
- </div>
- </div>
- </template>
-
- <!-- Result for Numeric Data -->
- <template id="result_number" name="Number Result">
- <t t-set="number_result" t-value="prepare_result"/>
- <t t-set="text_result" t-value="number_result['input_lines']" />
- <span class="float-right mt8">
- <span class="badge badge-secondary only_left_radius">Sum </span> <span class="badge badge-info only_right_radius" t-esc="number_result['sum']"></span>
- <span class="badge badge-secondary only_left_radius">Maximum </span> <span class="badge badge-success only_right_radius" t-esc="number_result['max']"></span>
- <span class="badge badge-secondary only_left_radius">Minimum </span> <span class="badge badge-danger only_right_radius" t-esc="number_result['min']"></span>
- <span class="badge badge-secondary only_left_radius">Average </span> <span class="badge badge-warning only_right_radius" t-esc="number_result['average']"></span>
- </span>
- <ul class="nav nav-tabs d-print-none" role="tablist">
- <li class="nav-item">
- <a t-att-href="'#most_common_%d' % question.id" t-att-aria-controls="'most_common_%d' % question.id" class="nav-link active" data-toggle="tab" role="tab">
- <i class="fa fa-list-ol"></i>
- Most Common <span t-esc="len(number_result['most_common'])"></span>
- </a>
- </li>
- <li class="nav-item">
- <a t-att-href="'#data_question_%d' % question.id" t-att-aria-controls="'data_question_%d' % question.id" class="nav-link" data-toggle="tab" role="tab">
- <i class="fa fa-list-alt"></i>
- All Data
- </a>
- </li>
- </ul>
- <div class="tab-content">
- <div role="tabpanel" class="tab-pane active with-3d-shadow with-transitions" t-att-id="'most_common_%d' % question.id">
- <table class="table table-hover table-sm">
- <thead>
- <tr>
- <th>User Responses</th>
- <th>Occurence</th>
- </tr>
- </thead>
- <tbody>
- <tr t-foreach="number_result['most_common']" t-as="row">
- <td>
- <span t-esc="row[0]"></span>
- </td>
- <td>
- <span t-esc="row[1]"></span>
- </td>
- </tr>
- </tbody>
- </table>
- </div>
- <div role="tabpanel" class="tab-pane" t-att-id="'data_question_%d' % question.id">
- <table class="table table-hover table-sm" t-att-id="'table_question_%d' % question.id">
- <thead>
- <tr>
- <th>#</th>
- <th>User Responses</th>
- </tr>
- </thead>
- <tbody>
- <tr class="d-none" t-foreach="number_result['input_lines']" t-as="user_input">
- <td><a t-att-href="'%s/%s' % (user_input.user_input_id.print_url, user_input.user_input_id.token)"><t t-esc="user_input_index + 1"></t></a></td>
- <td><span t-field="user_input.value_number"></span><br/></td>
- </tr>
- </tbody>
- </table>
- <t t-call="survey.pagination"/>
- </div>
- </div>
- </template>
- <template id="no_result" name="Survey Result">
- <t t-call="survey.layout">
- <div class="container">
- <t t-call="survey.back" />
- <h2><span t-field='survey.title'></span></h2>
- <div class="jumbotron mt32">
- <h2 class="text-muted">
- <span>
- <i style="font-size:1.8em" class="fa fa-users float-right" role="img" aria-label="No answer" title="No answer"></i>
- </span>
- Sorry, No one answered this survey yet
- </h2>
- </div>
- </div>
- </t>
- </template>
- <!-- Pagination Element -->
- <template id="pagination" name="Survey Result">
- <t t-set="record_limit" t-value="10"/><!-- Change This record_limit to change number of record per page-->
- <ul t-att-id="'pagination_%d' % question.id" class="pagination" t-att-data-question_id="question.id" t-att-data-record_limit="record_limit">
- <t t-if="len(text_result) > record_limit">
- <li t-foreach="page_range(len(text_result), record_limit)" t-as="num" class="page-item">
- <a href="#" class="page-link" t-esc="num"></a>
- </li>
- </t>
- </ul>
- </template>
-</data>
-</odoo>
diff --git a/addons/survey/views/survey_stage_views.xml b/addons/survey/views/survey_stage_views.xml
new file mode 100644
index 00000000000..f536981b260
--- /dev/null
+++ b/addons/survey/views/survey_stage_views.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+<data>
+ <record model="ir.ui.view" id="survey_stage_form">
+ <field name="name">Form view for survey stage</field>
+ <field name="model">survey.stage</field>
+ <field name="arch" type="xml">
+ <form string="Stage">
+ <group col='4' colspan='4'>
+ <field name="name" />
+ <field name="closed" />
+ <field name="sequence" />
+ <field name="fold" />
+ </group>
+ </form>
+ </field>
+ </record>
+</data>
+</odoo> \ No newline at end of file
diff --git a/addons/survey/views/survey_survey_views.xml b/addons/survey/views/survey_survey_views.xml
new file mode 100644
index 00000000000..6e10e60398f
--- /dev/null
+++ b/addons/survey/views/survey_survey_views.xml
@@ -0,0 +1,182 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+<data>
+ <record model="ir.ui.view" id="survey_form">
+ <field name="name">Form view for survey</field>
+ <field name="model">survey.survey</field>
+ <field name="arch" type="xml">
+ <form string="Survey">
+ <field name="id" invisible="1"/>
+ <field name="tot_start_survey" invisible="1"/>
+ <field name="is_closed" invisible="1"/>
+ <header groups="survey.group_survey_manager">
+ <button name="action_test_survey" string="Test Survey" type="object" attrs="{'invisible': [('id', '=', False)]}"/>
+ <button name="action_print_survey" string="Print Survey" type="object" attrs="{'invisible': [('id', '=', False)]}"/>
+ <button name="action_send_survey" string="Share and invite by email" type="object" class="oe_highlight" attrs="{'invisible': [('id', '=', False)]}"/>
+ <button name="action_result_survey" string="View results" type="object" attrs="{'invisible': ['|',('id', '=', False), ('tot_start_survey', '!=', 0)]}"/>
+ <button name="action_result_survey" string="View results" type="object" class="oe_highlight" attrs="{'invisible': [('tot_start_survey', '=', 0)]}"/>
+ <field name="stage_id" widget="statusbar" options="{'clickable': '1'}"/>
+ </header>
+ <sheet>
+ <div class="oe_button_box" name="button_box">
+ <button name="action_survey_user_input"
+ type="object"
+ class="oe_stat_button"
+ icon="fa-pencil-square-o">
+ <field string="Answers" name="tot_comp_survey" widget="statinfo"/>
+ </button>
+ <button name="toggle_active" type="object" class="oe_stat_button" icon="fa-archive" attrs="{'invisible': [('is_closed', '=', False)]}">
+ <field name="active" widget="boolean_button" options='{"terminology": "archive"}'/>
+ </button>
+ </div>
+ <div class="oe_title" style="width: 100%;">
+ <label for="title" class="oe_edit_only"/>
+ <h1><field name="title" placeholder="Survey Title"/></h1>
+ </div>
+ <notebook>
+ <page string="Edit Pages and Questions">
+ <field name="page_ids" mode="tree" context="{'default_survey_id': active_id}" nolabel="1">
+ <tree>
+ <field name="sequence" widget="handle"/>
+ <field name="title"/>
+ <field name="question_ids"/>
+ </tree>
+ </field>
+ </page>
+ <page string="Options">
+ <group class="o_label_nowrap">
+ <field name="users_can_go_back" string="User can come back in the previous page"/>
+ <field name="auth_required"/>
+ <field name="quizz_mode" groups="base.group_no_one"/>
+ </group>
+ </page>
+ </notebook>
+ </sheet>
+ <div class="oe_chatter">
+ <field name="message_follower_ids" widget="mail_followers"/>
+ <field name="activity_ids" widget="mail_activity"/>
+ <field name="message_ids" widget="mail_thread"/>
+ </div>
+ </form>
+ </field>
+ </record>
+ <record model="ir.ui.view" id= "survey_tree">
+ <field name="name">Tree view for survey</field>
+ <field name="model">survey.survey</field>
+ <field name="arch" type="xml">
+ <tree string="Survey">
+ <field name="active" invisible="1"/>
+ <field name="title" />
+ <field name="stage_id" />
+ <field name="tot_sent_survey" string="Invitations sent" />
+ <field name="tot_start_survey" string="Started" />
+ <field name="tot_comp_survey" string="Completed" />
+ </tree>
+ </field>
+ </record>
+ <record model="ir.ui.view" id="survey_kanban">
+ <field name="name">Kanban view for survey</field>
+ <field name="model">survey.survey</field>
+ <field name="arch" type="xml">
+ <kanban default_group_by="stage_id">
+ <field name="stage_id" />
+ <field name="designed" />
+ <field name="title" />
+ <field name="tot_sent_survey" />
+ <field name="tot_start_survey" />
+ <field name="tot_comp_survey" />
+ <field name="color" />
+ <field name="auth_required" />
+ <field name="public_url" />
+ <field name="activity_ids" />
+ <field name="activity_state" />
+ <templates>
+ <div t-name="kanban-box" t-attf-class="oe_kanban_color_#{kanban_getcolor(record.color.raw_value)} oe_kanban_card oe_kanban_global_click">
+ <div class="o_dropdown_kanban dropdown" t-if="widget.editable">
+
+ <a role="button" class="dropdown-toggle o-no-caret btn" data-toggle="dropdown" href="#" aria-label="Dropdown menu" title="Dropdown menu">
+ <span class="fa fa-ellipsis-v"/>
+ </a>
+ <div class="dropdown-menu" role="menu">
+ <a role="menuitem" type="edit" class="dropdown-item">Edit Survey</a>
+ <a t-if="widget.deletable" role="menuitem" type="delete" class="dropdown-item">Delete</a>
+ <div role="separator" class="dropdown-divider"/>
+ <div role="separator" class="dropdown-item-text">Color</div>
+ <ul class="oe_kanban_colorpicker" data-field="color"/>
+ </div>
+ </div>
+ <div class="o_kanban_record_top">
+ <h4 class="o_kanban_record_title mb4"><field name="title" /></h4>
+ </div>
+ <div class="o_kanban_record_bottom">
+ <ul class="list-unstyled">
+ <li id="survey_test">
+ <t t-if="record.designed.raw_value"><a type="object" name="action_test_survey">Test</a></t>
+ <t t-if="! record.designed.raw_value"><a style="color: #aaaaaa;">Test</a></t>
+ </li>
+ <li>
+ <t t-if="record.designed.raw_value"><a type="object" name="action_send_survey">Share &amp;amp; Invite</a></t>
+ <t t-if="! record.designed.raw_value"><a style="color: #aaaaaa;">Share &amp;amp; Invite</a></t>
+ </li>
+ <li>
+ <t t-if="record.tot_start_survey.raw_value &gt; 0"><a name="action_result_survey" type="object">Analyze Answers</a> <span t-if="record.tot_start_survey.raw_value &gt; 0">(<field name="tot_start_survey" />)</span></t>
+ <t t-if="record.tot_start_survey.raw_value &lt;= 0"><a style="color: #aaaaaa;">Analyze Answers</a></t>
+ </li>
+ </ul>
+ </div>
+ <div class="o_kanban_record_bottom">
+ <div class="oe_kanban_bottom_left"/>
+ <div class="oe_kanban_bottom_right">
+ <field name="activity_ids" widget="kanban_activity" />
+ </div>
+ </div>
+ </div>
+ </templates>
+ </kanban>
+ </field>
+ </record>
+ <record id="survey_survey_view_search" model="ir.ui.view">
+ <field name="name">survey.survey.search</field>
+ <field name="model">survey.survey</field>
+ <field name="arch" type="xml">
+ <search string="Survey">
+ <field string="Title" name="title"/>
+ <filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
+ <separator/>
+ <filter string="My Activities" name="activities_my"
+ domain="[('activity_ids.user_id', '=', uid)]"/>
+ <separator/>
+ <filter string="Late Activities" name="activities_overdue"
+ domain="[('activity_ids.date_deadline', '&lt;', 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'))]"/>
+ <filter string="Upcoming Activities" name="activities_upcoming_all"
+ domain="[('activity_ids.date_deadline', '&gt;', context_today().strftime('%Y-%m-%d'))
+ ]"/>
+ </search>
+ </field>
+ </record>
+
+ <record model="ir.actions.act_window" id="action_survey_form">
+ <field name="name">Surveys</field>
+ <field name="res_model">survey.survey</field>
+ <field name="view_type">form</field>
+ <field name="view_mode">kanban,tree,form,activity</field>
+ <field name="help" type="html">
+ <p class="o_view_nocontent_smiling_face">
+ Add a new survey
+ </p><p>
+ You can create surveys for different purposes: customer opinion, services feedback, recruitment interviews, employee's periodical evaluations, marketing campaigns, etc.
+ </p><p>
+ Design easily your survey, send invitations to answer by email and analyse answers.
+ </p>
+ </field>
+ </record>
+ <act_window context="{'search_default_survey_id': [active_id], 'default_survey_id': active_id}" id="act_survey_pages" name="Pages" res_model="survey.page" src_model="survey.survey"/>
+ <act_window context="{'search_default_survey_id': [active_id], 'default_survey_id': active_id}" id="act_survey_question" name="Questions" res_model="survey.question" src_model="survey.survey"/>
+
+ <menuitem name="Surveys" id="menu_survey_form" action="action_survey_form" parent="menu_surveys" sequence="1"/>
+
+</data>
+</odoo>
diff --git a/addons/survey/views/survey_templates.xml b/addons/survey/views/survey_templates.xml
index 199e8bdd2e4..2b438713c8e 100644
--- a/addons/survey/views/survey_templates.xml
+++ b/addons/survey/views/survey_templates.xml
@@ -1,26 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
- <!-- survey assets -->
- <template id="survey_assets" name="Survey Results assets">
- <script src="/web/static/lib/nvd3/d3.v3.js"></script>
- <script src="/web/static/lib/nvd3/nv.d3.js"></script>
- <script type="text/javascript" src="/web/static/src/js/services/session.js"></script>
- <script type="text/javascript" src="/survey/static/src/js/survey_result.js" />
- <script type="text/javascript" src="/survey/static/src/js/survey.js" />
- <script type="text/javascript" src="/survey/static/src/js/tour_test_survey.js"></script>
- <script type="text/javascript" src="/web/static/src/js/fields/field_utils.js"></script>
-
- <t t-call="web._assets_frontend_helpers"/>
- <link href="/web/static/lib/nvd3/nv.d3.css" rel="stylesheet" type="text/css"></link>
- <t t-call="web._assets_bootstrap"/>
- <link rel="stylesheet" type="text/less" href="/web/static/src/scss/fonts.scss" />
- <!-- Some helper classes (like mt- mb-) are required for survey templates -->
- <link rel="stylesheet" type="text/less" href="/web_editor/static/src/scss/web_editor.common.scss"/>
- <link href="/survey/static/src/css/survey_print.css" rel="stylesheet" type="text/css"/>
- <link href="/survey/static/src/css/survey_result.css" rel="stylesheet" type="text/css"></link>
- </template>
-
<!-- Forbidden error messages-->
<template id="403">
<t t-call="survey.layout">
@@ -190,13 +170,13 @@
<span t-if="question.constr_mandatory" class="text-danger">*</span>
</h2>
<div t-field='question.description' class="text-muted oe_no_empty"/>
- <t t-if="question.type == 'free_text'"><t t-call="survey.free_text"/></t>
- <t t-if="question.type == 'textbox'"><t t-call="survey.textbox"/></t>
- <t t-if="question.type == 'numerical_box'"><t t-call="survey.numerical_box"/></t>
- <t t-if="question.type == 'date'"><t t-call="survey.date"/></t>
- <t t-if="question.type == 'simple_choice'"><t t-call="survey.simple_choice"/></t>
- <t t-if="question.type == 'multiple_choice'"><t t-call="survey.multiple_choice"/></t>
- <t t-if="question.type == 'matrix'"><t t-call="survey.matrix"/></t>
+ <t t-if="question.question_type == 'free_text'"><t t-call="survey.free_text"/></t>
+ <t t-if="question.question_type == 'textbox'"><t t-call="survey.textbox"/></t>
+ <t t-if="question.question_type == 'numerical_box'"><t t-call="survey.numerical_box"/></t>
+ <t t-if="question.question_type == 'date'"><t t-call="survey.date"/></t>
+ <t t-if="question.question_type == 'simple_choice'"><t t-call="survey.simple_choice"/></t>
+ <t t-if="question.question_type == 'multiple_choice'"><t t-call="survey.multiple_choice"/></t>
+ <t t-if="question.question_type == 'matrix'"><t t-call="survey.matrix"/></t>
<div class="js_errzone alert alert-danger" style="display:none;" role="alert"></div>
</div>
</t>
@@ -361,13 +341,13 @@
<span t-if="quizz_correction" class="badge badge-pill" t-att-data-score-question="question.id"></span>
</h2>
<t t-if="question.description"><div class="text-muted oe_no_empty" t-field='question.description'/></t>
- <t t-if="question.type == 'free_text'"><t t-call="survey.free_text"/></t>
- <t t-if="question.type == 'textbox'"><t t-call="survey.textbox"/></t>
- <t t-if="question.type == 'numerical_box'"><t t-call="survey.numerical_box"/></t>
- <t t-if="question.type == 'date'"><t t-call="survey.date"/></t>
- <t t-if="question.type == 'simple_choice'"><t t-call="survey.simple_choice"/></t>
- <t t-if="question.type == 'multiple_choice'"><t t-call="survey.multiple_choice"/></t>
- <t t-if="question.type == 'matrix'"><t t-call="survey.matrix"/></t>
+ <t t-if="question.question_type == 'free_text'"><t t-call="survey.free_text"/></t>
+ <t t-if="question.question_type == 'textbox'"><t t-call="survey.textbox"/></t>
+ <t t-if="question.question_type == 'numerical_box'"><t t-call="survey.numerical_box"/></t>
+ <t t-if="question.question_type == 'date'"><t t-call="survey.date"/></t>
+ <t t-if="question.question_type == 'simple_choice'"><t t-call="survey.simple_choice"/></t>
+ <t t-if="question.question_type == 'multiple_choice'"><t t-call="survey.multiple_choice"/></t>
+ <t t-if="question.question_type == 'matrix'"><t t-call="survey.matrix"/></t>
<div class="js_errzone alert alert-danger" style="display:none;" role="alert"></div>
</div>
</t>
@@ -379,5 +359,346 @@
</div>
</t>
</template>
+
+ <template id="result" name="Survey Result">
+ <t t-call="survey.layout">
+ <div class="oe_structure" id="oe_structure_survey_result_1"/>
+ <div class="container js_surveyresult">
+ <t t-call="survey.back" />
+ <div class="jumbotron mt32">
+ <h1><span t-field="survey.title" /> <span style="font-size:1.5em;" class="fa fa-bar-chart-o float-right " role="img" aria-label="Chart" title="Chart"/></h1>
+ <div t-field="survey.description" class="oe_no_empty" />
+ </div>
+ <div class="card d-print-none">
+ <div class="card-header"><span class="fa fa-filter"></span> Filters <span t-if="filter_display_data" class="float-right text-primary clear_survey_filter"><i class="fa fa-times"></i> Clear All Filters</span></div>
+ <div class="card-body">
+ <span t-if="filter_finish == True">
+ <span class="badge badge-secondary only_left_radius filter-all">All surveys</span><span class="badge badge-primary only_right_radius filter-finished">Finished surveys</span>
+ </span>
+ <span t-if="filter_finish == False">
+ <span class="badge badge-primary only_left_radius filter-all">All surveys</span><span class="badge badge-secondary only_right_radius filter-finished">Finished surveys</span>
+ </span>
+ <span t-foreach="filter_display_data" t-as="filter_data">
+ <span class="badge badge-primary only_left_radius"><i class="fa fa-filter" role="img" aria-label="Filter" title="Filter"></i></span><span class="badge badge-primary no_radius" t-esc="filter_data['question_text']"></span><span class="badge badge-success only_right_radius" t-esc="' > '.join(filter_data['labels'])"></span>
+ </span>
+ </div>
+ </div>
+ <div t-foreach="survey_dict['page_ids']" t-as='page_ids'>
+ <t t-set="page" t-value="page_ids['page']"/>
+ <h1 class="mt16" t-field='page.title'></h1>
+ <div t-field="page.description" class="oe_no_empty" />
+ <hr/>
+ <div t-foreach="page_ids['question_ids']" t-as='question_ids' class="mt16">
+ <t t-set="input_summary" t-value="question_ids['input_summary']"/>
+ <t t-set="question" t-value="question_ids['question']"/>
+ <t t-set="graph_data" t-value="question_ids['graph_data']"/>
+ <t t-set="prepare_result" t-value="question_ids['prepare_result']"/>
+ <h4>
+ <b>Question </b>
+ <span t-field='question.question'></span>
+ <t t-if="question.question_type == 'matrix'">
+ <small><span class="badge badge-secondary">Matrix: <span t-field='question.matrix_subtype'/></span></small>
+ </t>
+ <t t-if="question.question_type in ['simple_choice', 'multiple_choice']">
+ <small><span t-field='question.question_type' class="badge badge-secondary"></span></small>
+ </t>
+ <span class="float-right">
+ <span class="badge badge-success"><span t-esc="input_summary['answered']"></span> Answered</span>
+ <span class="badge badge-danger"><span t-esc="input_summary['skipped']"></span> Skipped</span>
+ </span>
+ </h4>
+ <t t-if="input_summary['answered'] != 0">
+ <t t-if="question.description">
+ <div class="text-muted oe_no_empty" t-field="question.description" />
+ </t>
+ <t t-if="question.question_type in ['textbox', 'free_text', 'date']">
+ <t t-call="survey.result_text"></t>
+ </t>
+ <t t-if="question.question_type in ['simple_choice', 'multiple_choice']">
+ <t t-call="survey.result_choice"></t>
+ </t>
+ <t t-if="question.question_type == 'matrix'">
+ <t t-call="survey.result_matrix"></t>
+ </t>
+ <t t-if="question.question_type == 'numerical_box'">
+ <t t-call="survey.result_number"></t>
+ </t>
+ </t>
+ <t t-if="input_summary['answered'] == 0">
+ <h2 style="padding-top:30px;padding-bottom:30px;text-align:center;" class="text-muted">Sorry, No one answered this question.</h2>
+ </t>
+ </div>
+ </div>
+ </div>
+ <div class="oe_structure" id="oe_structure_survey_result_2"/>
+ </t>
+ </template>
+
+ <!-- Result for free_text,textbox and date -->
+ <template id="result_text" name="Text Result">
+ <table class="table table-hover table-sm" t-att-id="'table_question_%d' % question.id">
+ <thead>
+ <tr>
+ <th>#</th>
+ <th>User Responses</th>
+ </tr>
+ </thead>
+ <tbody>
+ <t t-set="text_result" t-value="prepare_result"/>
+ <tr t-foreach="text_result" t-as="user_input">
+ <td><a t-att-href="'%s/%s' % (user_input.user_input_id.print_url, user_input.user_input_id.token)"><t t-esc="user_input_index + 1"></t></a></td>
+ <t t-if="question.question_type == 'free_text'">
+ <td>
+ <a t-att-href="'%s/%s' % (user_input.user_input_id.print_url, user_input.user_input_id.token)" t-field="user_input.value_free_text"></a><br/>
+ </td>
+ </t>
+ <t t-if="question.question_type == 'textbox'">
+ <td>
+ <a t-att-href="'%s/%s' % (user_input.user_input_id.print_url, user_input.user_input_id.token)" t-field="user_input.value_text"></a><br/>
+ </td>
+ </t>
+ <t t-if="question.question_type == 'date'">
+ <td>
+ <a t-att-href="'%s/%s' % (user_input.user_input_id.print_url, user_input.user_input_id.token)" t-field="user_input.value_date"></a><br/>
+ </td>
+ </t>
+ </tr>
+ </tbody>
+ </table>
+ <t t-call="survey.pagination" />
+ </template>
+
+ <!-- Result for comments -->
+ <template id="result_comments" name="Text Result">
+ <!-- a 'comments' variable must be set an must contain a list of browse records of user input lines -->
+ <table class="table table-hover table-sm" t-att-id="'table_question_%d' % question.id">
+ <thead>
+ <tr>
+ <th>#</th>
+ <th>Comment</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr t-foreach="comments" t-as="user_input">
+ <td><a t-att-href="'%s/%s' % (user_input.user_input_id.print_url, user_input.user_input_id.token)"><t t-esc="user_input_index + 1"></t></a></td>
+ <td>
+ <span t-field="user_input.value_text"></span><br/>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </template>
+
+ <!-- Result for simple_choice and multiple_choice -->
+ <template id="result_choice" name="Choice Result">
+ <div>
+ <!-- Tabs -->
+ <ul class="nav nav-tabs d-print-none" role="tablist">
+ <li class="nav-item" t-if="question.question_type != 'simple_choice'">
+ <a t-att-href="'#graph_question_%d' % question.id" t-att-aria-controls="'graph_question_%d' % question.id" class="nav-link active" data-toggle="tab" role="tab">
+ <i class="fa fa-bar-chart-o"></i> Graph
+ </a>
+ </li>
+ <li class="nav-item" t-if="question.question_type == 'simple_choice'">
+ <a t-att-href="'#graph_question_%d' % question.id" t-att-aria-controls="'graph_question_%d' % question.id" class="nav-link active" data-toggle="tab" role="tab">
+ <i class="fa fa-bar-chart-o"></i> Pie Chart
+ </a>
+ </li>
+ <li class="nav-item">
+ <a t-att-href="'#data_question_%d' % question.id" t-att-aria-controls="'data_question_%d' % question.id" class="nav-link" data-toggle="tab" role="tab">
+ <i class="fa fa-list-alt"></i> Data
+ </a>
+ </li>
+ </ul>
+ <div class="tab-content">
+ <div role="tabpanel" class="tab-pane active survey_graph" t-if="question.question_type != 'simple_choice'" t-att-id="'graph_question_%d' % question.id" t-att-data-question_id="question.id" data-graph_type="bar" t-att-graph-data="graph_data">
+ <!-- svg element for drawing bar chart -->
+ <svg style="height:20em"></svg>
+ </div>
+ <div role="tabpanel" class="tab-pane active survey_graph" t-if="question.question_type == 'simple_choice'" t-att-id="'graph_question_%d' % question.id" t-att-data-question_id="question.id" data-graph_type="pie" t-att-graph-data="graph_data">
+ <!-- svg element for drawing pie chart -->
+ <svg style="height:20em"></svg>
+ </div>
+ <div role="tabpanel" class="tab-pane" t-att-id="'data_question_%d' % question.id">
+ <table class="table table-hover table-sm">
+ <thead>
+ <tr>
+ <th>Answer Choices</th>
+ <th>User Responses</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr t-foreach="prepare_result['answers']" t-as="user_input">
+ <td>
+ <p t-esc="user_input['text']"></p>
+ </td>
+ <td class="survey_answer">
+ <span t-esc="round(user_input['count']*100.0/(input_summary['answered'] or 1),2)"></span> %
+ <span t-esc="user_input['count']" class="badge badge-primary">Vote</span>
+ <i class="fa fa-filter text-primary invisible survey_filter" t-att-data-question_id="question.id" t-att-data-answer_id="user_input['answer_id']" role="img" aria-label="Filter question" title="Filter question"/>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </div>
+ <!-- handle comments -->
+ <div>
+ <t t-set="comments" t-value="prepare_result['comments']" />
+ <t t-if="comments">
+ <t t-call="survey.result_comments" />
+ </t>
+ </div>
+
+ </div>
+ </template>
+
+ <!-- Result for matrix -->
+ <template id="result_matrix" name="Matrix Result">
+ <t t-set="matrix_result" t-value="prepare_result"/>
+ <!-- Tabs -->
+ <ul class="nav nav-tabs d-print-none" role="tablist">
+ <li class="nav-item">
+ <a t-att-href="'#graph_question_%d' % question.id" t-att-aria-controls="'graph_question_%d' % question.id" class="nav-link active" data-toggle="tab" role="tab">
+ <i class="fa fa-bar-chart"></i>
+ Graph
+ </a>
+ </li>
+ <li class="nav-item">
+ <a t-att-href="'#data_question_%d' % question.id" t-att-aria-controls="'data_question_%d' % question.id" class="nav-link" data-toggle="tab" role="tab">
+ <i class="fa fa-list-alt"></i>
+ Data
+ </a>
+ </li>
+ </ul>
+ <div class="tab-content">
+ <div role="tabpanel" class="tab-pane active with-3d-shadow with-transitions survey_graph" t-att-id="'graph_question_%d' % question.id" t-att-data-question_id= "question.id" data-graph_type= "multi_bar" t-att-graph-data="graph_data">
+ <!-- svg element for drawing Multibar chart -->
+ <svg style="height:20em"></svg>
+ </div>
+ <div role="tabpanel" class="tab-pane" t-att-id="'data_question_%d' % question.id">
+ <table class="table table-hover table-sm text-right">
+ <thead>
+ <tr>
+ <th></th>
+ <th class="text-right" t-foreach="matrix_result['answers']" t-as="answer_id">
+ <span t-esc="matrix_result['answers'][answer_id]"></span>
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr t-foreach="matrix_result['rows']" t-as="row_id">
+ <td>
+ <span t-esc="matrix_result['rows'][row_id]"></span>
+ </td>
+ <td class="survey_answer" t-foreach="matrix_result['answers']" t-as="answer_id">
+ <span t-esc="round(matrix_result['result'][(row_id,answer_id)]*100.0/(input_summary['answered'] or 1),2)"></span> %
+ <span class="badge badge-primary" t-esc="matrix_result['result'][(row_id,answer_id)]"></span><i class="fa fa-filter text-primary invisible survey_filter" t-att-data-question_id="question.id" t-att-data-row_id="row_id" t-att-data-answer_id="answer_id" role="img" aria-label="Survey filter" title="Survey filter"></i>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ <!-- handle comments to matrix -->
+ <div>
+ <t t-set="comments" t-value="matrix_result['comments']" />
+ <t t-if="comments">
+ <t t-call="survey.result_comments" />
+ </t>
+ </div>
+ </div>
+ </template>
+
+ <!-- Result for Numeric Data -->
+ <template id="result_number" name="Number Result">
+ <t t-set="number_result" t-value="prepare_result"/>
+ <t t-set="text_result" t-value="number_result['input_lines']" />
+ <span class="float-right mt8">
+ <span class="badge badge-secondary only_left_radius">Sum </span> <span class="badge badge-info only_right_radius" t-esc="number_result['sum']"></span>
+ <span class="badge badge-secondary only_left_radius">Maximum </span> <span class="badge badge-success only_right_radius" t-esc="number_result['max']"></span>
+ <span class="badge badge-secondary only_left_radius">Minimum </span> <span class="badge badge-danger only_right_radius" t-esc="number_result['min']"></span>
+ <span class="badge badge-secondary only_left_radius">Average </span> <span class="badge badge-warning only_right_radius" t-esc="number_result['average']"></span>
+ </span>
+ <ul class="nav nav-tabs d-print-none" role="tablist">
+ <li class="nav-item">
+ <a t-att-href="'#most_common_%d' % question.id" t-att-aria-controls="'most_common_%d' % question.id" class="nav-link active" data-toggle="tab" role="tab">
+ <i class="fa fa-list-ol"></i>
+ Most Common <span t-esc="len(number_result['most_common'])"></span>
+ </a>
+ </li>
+ <li class="nav-item">
+ <a t-att-href="'#data_question_%d' % question.id" t-att-aria-controls="'data_question_%d' % question.id" class="nav-link" data-toggle="tab" role="tab">
+ <i class="fa fa-list-alt"></i>
+ All Data
+ </a>
+ </li>
+ </ul>
+ <div class="tab-content">
+ <div role="tabpanel" class="tab-pane active with-3d-shadow with-transitions" t-att-id="'most_common_%d' % question.id">
+ <table class="table table-hover table-sm">
+ <thead>
+ <tr>
+ <th>User Responses</th>
+ <th>Occurence</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr t-foreach="number_result['most_common']" t-as="row">
+ <td>
+ <span t-esc="row[0]"></span>
+ </td>
+ <td>
+ <span t-esc="row[1]"></span>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ <div role="tabpanel" class="tab-pane" t-att-id="'data_question_%d' % question.id">
+ <table class="table table-hover table-sm" t-att-id="'table_question_%d' % question.id">
+ <thead>
+ <tr>
+ <th>#</th>
+ <th>User Responses</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr class="d-none" t-foreach="number_result['input_lines']" t-as="user_input">
+ <td><a t-att-href="'%s/%s' % (user_input.user_input_id.print_url, user_input.user_input_id.token)"><t t-esc="user_input_index + 1"></t></a></td>
+ <td><span t-field="user_input.value_number"></span><br/></td>
+ </tr>
+ </tbody>
+ </table>
+ <t t-call="survey.pagination"/>
+ </div>
+ </div>
+ </template>
+ <template id="no_result" name="Survey Result">
+ <t t-call="survey.layout">
+ <div class="container">
+ <t t-call="survey.back" />
+ <h2><span t-field='survey.title'></span></h2>
+ <div class="jumbotron mt32">
+ <h2 class="text-muted">
+ <span>
+ <i style="font-size:1.8em" class="fa fa-users float-right" role="img" aria-label="No answer" title="No answer"></i>
+ </span>
+ Sorry, No one answered this survey yet
+ </h2>
+ </div>
+ </div>
+ </t>
+ </template>
+ <!-- Pagination Element -->
+ <template id="pagination" name="Survey Result">
+ <t t-set="record_limit" t-value="10"/><!-- Change This record_limit to change number of record per page-->
+ <ul t-att-id="'pagination_%d' % question.id" class="pagination" t-att-data-question_id="question.id" t-att-data-record_limit="record_limit">
+ <t t-if="len(text_result) > record_limit">
+ <li t-foreach="page_range(len(text_result), record_limit)" t-as="num" class="page-item">
+ <a href="#" class="page-link" t-esc="num"></a>
+ </li>
+ </t>
+ </ul>
+ </template>
</data>
</odoo>
diff --git a/addons/survey/views/survey_user_views.xml b/addons/survey/views/survey_user_views.xml
new file mode 100644
index 00000000000..1e35a182e06
--- /dev/null
+++ b/addons/survey/views/survey_user_views.xml
@@ -0,0 +1,222 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+<data>
+ <!-- USER INPUTS -->
+ <record id="survey_user_input_search" model="ir.ui.view">
+ <field name="name">survey_user_input_search</field>
+ <field name="model">survey.user_input</field>
+ <field name="arch" type="xml">
+ <search string="Search Survey">
+ <field name="survey_id"/>
+ <field name="email"/>
+ <field name="partner_id"/>
+ <filter name="completed" string="Completed" domain="[('state', '=', 'done')]"/>
+ <filter string="Partially Completed" name="partially_completed" domain="[('state', '=', 'skip')]"/>
+ <filter string="New" name="new" domain="[('state', '=', 'new')]"/>
+ <separator/>
+ <filter string="Test Entries" name="test" domain="[('test_entry','=', True)]"/>
+ <group expand="0" string="Group By">
+ <filter name="group_by_survey" string="Survey" domain="[]" context="{'group_by': 'survey_id'}"/>
+ <filter string="Email" name="group_by_email" domain="[]" context="{'group_by': 'email'}"/>
+ <filter string="Partner" name="group_by_partner" domain="[]" context="{'group_by': 'partner_id'}"/>
+ </group>
+ </search>
+ </field>
+ </record>
+ <record model="ir.ui.view" id="survey_user_input_form">
+ <field name="name">survey_user_input_form</field>
+ <field name="model">survey.user_input</field>
+ <field name="arch" type="xml">
+ <form string="Survey User inputs" create="false">
+ <header>
+ <button name="action_survey_resend" string="Send Invitation Again" type="object" class="oe_highlight" attrs="{'invisible': ['|', ('input_type','=','manually'), ('state', '=', 'done')]}"/>
+ <button name="action_view_answers" states="done" string="Print These Answers" type="object" class="oe_highlight" />
+ <button name="action_survey_results" string="View Results" type="object" />
+ <field name="state" widget="statusbar"/>
+ </header>
+ <sheet>
+ <div class="oe_button_box" name="button_box"/>
+ <group col="2">
+ <group>
+ <field name="survey_id"/>
+ <field name="date_create"/>
+ <field name="input_type"/>
+ <field name="token" groups="base.group_no_one"/>
+ </group>
+ <group>
+ <field name="deadline"/>
+ <field name="partner_id"/>
+ <field name="email" widget="email"/>
+ <field name="test_entry" groups="base.group_no_one"/>
+ <field name="quizz_score" groups="base.group_no_one"/>
+ </group>
+ </group>
+ <field name="user_input_line_ids" mode="tree" attrs="{'readonly': False}">
+ <tree>
+ <field name="page_id"/>
+ <field name="question_id"/>
+ <field name="answer_type"/>
+ <field name="skipped"/>
+ <field name="date_create"/>
+ <field name="quizz_mark" groups="base.group_no_one"/>
+ </tree>
+ </field>
+ </sheet>
+ </form>
+ </field>
+ </record>
+ <record model="ir.ui.view" id="survey_user_input_tree">
+ <field name="name">survey_user_input_tree</field>
+ <field name="model">survey.user_input</field>
+ <field name="arch" type="xml">
+ <tree string="Survey User inputs" decoration-muted="test_entry == True" create="false">
+ <field name="survey_id"/>
+ <field name="date_create"/>
+ <field name="deadline"/>
+ <field name="partner_id"/>
+ <field name="email"/>
+ <field name="input_type"/>
+ <field name="state"/>
+ <field name="test_entry" invisible="True"/>
+ <field name="quizz_score" groups="base.group_no_one"/>
+ </tree>
+ </field>
+ </record>
+
+ <record id="view_survey_user_input_kanban" model="ir.ui.view">
+ <field name="name">survey.user_input.kanban</field>
+ <field name="model">survey.user_input</field>
+ <field name="arch" type="xml">
+ <kanban create="false">
+ <field name="survey_id"/>
+ <field name="date_create"/>
+ <field name="partner_id"/>
+ <field name="email"/>
+ <field name="input_type"/>
+ <field name="state"/>
+ <templates>
+ <t t-name="kanban-box">
+ <div t-attf-class="oe_kanban_global_click">
+ <div class="o_kanban_record_top">
+ <div class="o_kanban_record_headings">
+ <strong class="o_kanban_record_title"><t t-esc="record.survey_id.value"/></strong>
+ </div>
+ <span class="badge badge-pill"><t t-esc="record.input_type.value"/></span>
+ </div>
+ <div class="o_kanban_record_bottom">
+ <div class="oe_kanban_bottom_left">
+ <field name="date_create"/>
+ </div>
+ <div class="oe_kanban_bottom_right mr4">
+ <field name="state" widget="kanban_label_selection" options="{'classes': {'new': 'default', 'done': 'success', 'skip':'warning'}}"/>
+ </div>
+ </div>
+ </div>
+ </t>
+ </templates>
+ </kanban>
+ </field>
+ </record>
+
+ <record model="ir.actions.act_window" id="action_survey_user_input">
+ <field name="name">Answers</field>
+ <field name="res_model">survey.user_input</field>
+ <field name="view_mode">tree,kanban,form</field>
+ <field name="view_id" ref="survey_user_input_tree"></field>
+ <field name="search_view_id" ref="survey_user_input_search"/>
+ <field name="context">{'search_default_group_by_survey': True}</field>
+ <field name="help" type="html">
+ <p class="o_view_nocontent_empty_folder">
+ Nobody has replied to your surveys yet
+ </p>
+ </field>
+ </record>
+ <record id="action_selected_survey_user_input" model="ir.actions.act_window">
+ <field name="name">Survey User input</field>
+ <field name="res_model">survey.user_input</field>
+ <field name="view_mode">tree,form</field>
+ <field name="view_id" ref="survey_user_input_tree"></field>
+ <field name="search_view_id" ref="survey_user_input_search"/>
+ <field name="context">{'search_default_survey_id': active_id, 'default_survey_id': active_id}</field>
+ <field name="help" type="html">
+ <p class="o_view_nocontent_empty_folder">
+ Nobody has replied to your surveys yet
+ </p>
+ </field>
+ </record>
+
+ <!-- USER INPUT LINES
+ .. note:: these views are useful mainly for technical users/administrators -->
+ <record model="ir.ui.view" id="survey_user_input_line_form">
+ <field name="name">survey_user_input_line_form</field>
+ <field name="model">survey.user_input_line</field>
+ <field name="arch" type="xml">
+ <form string="User input line details" create="false">
+ <sheet>
+ <group col="4">
+ <field name="question_id"/>
+ <field name="date_create"/>
+ <field name="answer_type"/>
+ <field name="skipped" />
+ <field name="quizz_mark" groups="base.group_no_one"/>
+ </group>
+ <group>
+ <field name="value_text" colspan='2' attrs="{'invisible': [('answer_type','!=','text')]}"/>
+ <field name="value_number" colspan='2' attrs="{'invisible': [('answer_type','!=','number')]}"/>
+ <field name="value_date" colspan='2' attrs="{'invisible': [('answer_type','!=','date')]}"/>
+ <field name="value_free_text" colspan='2' attrs="{'invisible': [('answer_type','!=','free_text')]}"/>
+ <field name="value_suggested_row" colspan='2' />
+ <field name="value_suggested" colspan='2' attrs="{'invisible': [('answer_type','!=','suggestion')]}"/>
+ </group>
+ </sheet>
+ </form>
+ </field>
+ </record>
+ <record model="ir.ui.view" id="survey_response_line_tree">
+ <field name="name">survey_response_line_tree</field>
+ <field name="model">survey.user_input_line</field>
+ <field name="arch" type="xml">
+ <tree string="Survey Answer Line" create="false">
+ <field name="survey_id"/>
+ <field name="user_input_id"/>
+ <field name="page_id"/>
+ <field name="question_id"/>
+ <field name="date_create"/>
+ <field name="answer_type"/>
+ <field name="skipped"/>
+ <field name="quizz_mark" groups="base.group_no_one"/>
+ </tree>
+ </field>
+ </record>
+ <record id="survey_response_line_search" model="ir.ui.view">
+ <field name="name">survey_response_line_search</field>
+ <field name="model">survey.user_input_line</field>
+ <field name="arch" type="xml">
+ <search string="Search User input lines">
+ <field name="user_input_id"/>
+ <field name="survey_id"/>
+ <group expand="1" string="Group By">
+ <filter name="group_by_survey" string="Survey" domain="[]" context="{'group_by':'survey_id'}"/>
+ <filter name="group_by_user_input" string="User Input" domain="[]" context="{'group_by':'user_input_id'}"/>
+ </group>
+ </search>
+ </field>
+ </record>
+
+ <record model="ir.actions.act_window" id="action_survey_user_input_line">
+ <field name="name">Survey User Input lines</field>
+ <field name="res_model">survey.user_input_line</field>
+ <field name="view_mode">tree,form</field>
+ <field name="search_view_id" ref="survey_response_line_search"/>
+ <field name="context">{'search_default_group_by_survey': True, 'search_default_group_by_user_input': True}</field>
+ <field name="help" type="html">
+ <p class="o_view_nocontent_empty_folder">
+ No user input lines found
+ </p>
+ </field>
+ </record>
+
+ <menuitem name="Answers" id="menu_survey_type_form1" action="action_survey_user_input" parent="menu_surveys" sequence="1"/>
+ <menuitem name="User Input Lines" id="menu_survey_response_line_form" action="action_survey_user_input_line" parent="menu_surveys_configuration" sequence="4"/>
+</data>
+</odoo> \ No newline at end of file
diff --git a/addons/survey/views/survey_views.xml b/addons/survey/views/survey_views.xml
deleted file mode 100644
index 1ffc25eb948..00000000000
--- a/addons/survey/views/survey_views.xml
+++ /dev/null
@@ -1,732 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<odoo>
- <data>
-
- <!-- USER INPUTS -->
- <record id="survey_user_input_search" model="ir.ui.view">
- <field name="name">survey_user_input_search</field>
- <field name="model">survey.user_input</field>
- <field name="arch" type="xml">
- <search string="Search Survey">
- <field name="survey_id"/>
- <field name="email"/>
- <field name="partner_id"/>
- <filter name="completed" string="Completed" domain="[('state', '=', 'done')]"/>
- <filter string="Partially Completed" name="partially_completed" domain="[('state', '=', 'skip')]"/>
- <filter string="New" name="new" domain="[('state', '=', 'new')]"/>
- <separator/>
- <filter string="Test Entries" name="test" domain="[('test_entry','=', True)]"/>
- <group expand="0" string="Group By">
- <filter name="group_by_survey" string="Survey" domain="[]" context="{'group_by': 'survey_id'}"/>
- <filter string="Email" name="group_by_email" domain="[]" context="{'group_by': 'email'}"/>
- <filter string="Partner" name="group_by_partner" domain="[]" context="{'group_by': 'partner_id'}"/>
- </group>
- </search>
- </field>
- </record>
- <record model="ir.ui.view" id="survey_user_input_form">
- <field name="name">survey_user_input_form</field>
- <field name="model">survey.user_input</field>
- <field name="arch" type="xml">
- <form string="Survey User inputs" create="false">
- <header>
- <button name="action_survey_resend" string="Send Invitation Again" type="object" class="oe_highlight" attrs="{'invisible': ['|', ('type','=','manually'), ('state', '=', 'done')]}"/>
- <button name="action_view_answers" states="done" string="Print These Answers" type="object" class="oe_highlight" />
- <button name="action_survey_results" string="View Results" type="object" />
- <field name="state" widget="statusbar"/>
- </header>
- <sheet>
- <div class="oe_button_box" name="button_box"/>
- <group col="2">
- <group>
- <field name="survey_id"/>
- <field name="date_create"/>
- <field name="type"/>
- <field name="token" groups="base.group_no_one"/>
- </group>
- <group>
- <field name="deadline"/>
- <field name="partner_id"/>
- <field name="email" widget="email"/>
- <field name="test_entry" groups="base.group_no_one"/>
- <field name="quizz_score" groups="base.group_no_one"/>
- </group>
- </group>
- <field name="user_input_line_ids" mode="tree" attrs="{'readonly': False}">
- <tree>
- <field name="page_id"/>
- <field name="question_id"/>
- <field name="answer_type"/>
- <field name="skipped"/>
- <field name="date_create"/>
- <field name="quizz_mark" groups="base.group_no_one"/>
- </tree>
- </field>
- </sheet>
- </form>
- </field>
- </record>
- <record model="ir.ui.view" id="survey_user_input_tree">
- <field name="name">survey_user_input_tree</field>
- <field name="model">survey.user_input</field>
- <field name="arch" type="xml">
- <tree string="Survey User inputs" decoration-muted="test_entry == True" create="false">
- <field name="survey_id"/>
- <field name="date_create"/>
- <field name="deadline"/>
- <field name="partner_id"/>
- <field name="email"/>
- <field name="type"/>
- <field name="state"/>
- <field name="test_entry" invisible="True"/>
- <field name="quizz_score" groups="base.group_no_one"/>
- </tree>
- </field>
- </record>
-
- <record id="view_survey_user_input_kanban" model="ir.ui.view">
- <field name="name">survey.user_input.kanban</field>
- <field name="model">survey.user_input</field>
- <field name="arch" type="xml">
- <kanban create="false">
- <field name="survey_id"/>
- <field name="date_create"/>
- <field name="partner_id"/>
- <field name="email"/>
- <field name="type"/>
- <field name="state"/>
- <templates>
- <t t-name="kanban-box">
- <div t-attf-class="oe_kanban_global_click">
- <div class="o_kanban_record_top">
- <div class="o_kanban_record_headings">
- <strong class="o_kanban_record_title"><t t-esc="record.survey_id.value"/></strong>
- </div>
- <span class="badge badge-pill"><t t-esc="record.type.value"/></span>
- </div>
- <div class="o_kanban_record_bottom">
- <div class="oe_kanban_bottom_left">
- <field name="date_create"/>
- </div>
- <div class="oe_kanban_bottom_right mr4">
- <field name="state" widget="kanban_label_selection" options="{'classes': {'new': 'default', 'done': 'success', 'skip':'warning'}}"/>
- </div>
- </div>
- </div>
- </t>
- </templates>
- </kanban>
- </field>
- </record>
-
- <record model="ir.actions.act_window" id="action_survey_user_input">
- <field name="name">Answers</field>
- <field name="res_model">survey.user_input</field>
- <field name="view_mode">tree,kanban,form</field>
- <field name="view_id" ref="survey_user_input_tree"></field>
- <field name="search_view_id" ref="survey_user_input_search"/>
- <field name="context">{'search_default_group_by_survey': True}</field>
- <field name="help" type="html">
- <p class="o_view_nocontent_empty_folder">
- Nobody has replied to your surveys yet
- </p>
- </field>
- </record>
- <record id="action_selected_survey_user_input" model="ir.actions.act_window">
- <field name="name">Survey User input</field>
- <field name="res_model">survey.user_input</field>
- <field name="view_mode">tree,form</field>
- <field name="view_id" ref="survey_user_input_tree"></field>
- <field name="search_view_id" ref="survey_user_input_search"/>
- <field name="context">{'search_default_survey_id': active_id, 'default_survey_id': active_id}</field>
- <field name="help" type="html">
- <p class="o_view_nocontent_empty_folder">
- Nobody has replied to your surveys yet
- </p>
- </field>
- </record>
-
- <!-- SURVEY -->
- <record model="ir.ui.view" id="survey_form">
- <field name="name">Form view for survey</field>
- <field name="model">survey.survey</field>
- <field name="arch" type="xml">
- <form string="Survey">
- <field name="id" invisible="1"/>
- <field name="tot_start_survey" invisible="1"/>
- <field name="is_closed" invisible="1"/>
- <header groups="survey.group_survey_manager">
- <button name="action_test_survey" string="Test Survey" type="object" attrs="{'invisible': [('id', '=', False)]}"/>
- <button name="action_print_survey" string="Print Survey" type="object" attrs="{'invisible': [('id', '=', False)]}"/>
- <button name="action_send_survey" string="Share and invite by email" type="object" class="oe_highlight" attrs="{'invisible': [('id', '=', False)]}"/>
- <button name="action_result_survey" string="View results" type="object" attrs="{'invisible': ['|',('id', '=', False), ('tot_start_survey', '!=', 0)]}"/>
- <button name="action_result_survey" string="View results" type="object" class="oe_highlight" attrs="{'invisible': [('tot_start_survey', '=', 0)]}"/>
- <field name="stage_id" widget="statusbar" options="{'clickable': '1'}"/>
- </header>
- <sheet>
- <div class="oe_button_box" name="button_box">
- <button name="action_survey_user_input"
- type="object"
- class="oe_stat_button"
- icon="fa-pencil-square-o">
- <field string="Answers" name="tot_comp_survey" widget="statinfo"/>
- </button>
- <button name="toggle_active" type="object" class="oe_stat_button" icon="fa-archive" attrs="{'invisible': [('is_closed', '=', False)]}">
- <field name="active" widget="boolean_button" options='{"terminology": "archive"}'/>
- </button>
- </div>
- <div class="oe_title" style="width: 100%;">
- <label for="title" class="oe_edit_only"/>
- <h1><field name="title" placeholder="Survey Title"/></h1>
- </div>
- <notebook>
- <page string="Edit Pages and Questions">
- <field name="page_ids" mode="tree" context="{'default_survey_id': active_id}" nolabel="1">
- <tree>
- <field name="sequence" widget="handle"/>
- <field name="title"/>
- <field name="question_ids"/>
- </tree>
- </field>
- </page>
- <page string="Options">
- <group class="o_label_nowrap">
- <field name="users_can_go_back" string="User can come back in the previous page"/>
- <field name="auth_required"/>
- <field name="quizz_mode" groups="base.group_no_one"/>
- </group>
- </page>
- </notebook>
- </sheet>
- <div class="oe_chatter">
- <field name="message_follower_ids" widget="mail_followers"/>
- <field name="activity_ids" widget="mail_activity"/>
- <field name="message_ids" widget="mail_thread"/>
- </div>
- </form>
- </field>
- </record>
- <record model="ir.ui.view" id= "survey_tree">
- <field name="name">Tree view for survey</field>
- <field name="model">survey.survey</field>
- <field name="arch" type="xml">
- <tree string="Survey">
- <field name="active" invisible="1"/>
- <field name="title" />
- <field name="stage_id" />
- <field name="tot_sent_survey" string="Invitations sent" />
- <field name="tot_start_survey" string="Started" />
- <field name="tot_comp_survey" string="Completed" />
- </tree>
- </field>
- </record>
- <record model="ir.ui.view" id="survey_kanban">
- <field name="name">Kanban view for survey</field>
- <field name="model">survey.survey</field>
- <field name="arch" type="xml">
- <kanban default_group_by="stage_id">
- <field name="stage_id" />
- <field name="designed" />
- <field name="title" />
- <field name="tot_sent_survey" />
- <field name="tot_start_survey" />
- <field name="tot_comp_survey" />
- <field name="color" />
- <field name="auth_required" />
- <field name="public_url" />
- <field name="activity_ids" />
- <field name="activity_state" />
- <templates>
- <div t-name="kanban-box" t-attf-class="oe_kanban_color_#{kanban_getcolor(record.color.raw_value)} oe_kanban_card oe_kanban_global_click">
- <div class="o_dropdown_kanban dropdown" t-if="widget.editable">
-
- <a role="button" class="dropdown-toggle o-no-caret btn" data-toggle="dropdown" href="#" aria-label="Dropdown menu" title="Dropdown menu">
- <span class="fa fa-ellipsis-v"/>
- </a>
- <div class="dropdown-menu" role="menu">
- <a role="menuitem" type="edit" class="dropdown-item">Edit Survey</a>
- <a t-if="widget.deletable" role="menuitem" type="delete" class="dropdown-item">Delete</a>
- <div role="separator" class="dropdown-divider"/>
- <div role="separator" class="dropdown-item-text">Color</div>
- <ul class="oe_kanban_colorpicker" data-field="color"/>
- </div>
- </div>
- <div class="o_kanban_record_top">
- <h4 class="o_kanban_record_title mb4"><field name="title" /></h4>
- </div>
- <div class="o_kanban_record_bottom">
- <ul class="list-unstyled">
- <li id="survey_test">
- <t t-if="record.designed.raw_value"><a type="object" name="action_test_survey">Test</a></t>
- <t t-if="! record.designed.raw_value"><a style="color: #aaaaaa;">Test</a></t>
- </li>
- <li>
- <t t-if="record.designed.raw_value"><a type="object" name="action_send_survey">Share &amp;amp; Invite</a></t>
- <t t-if="! record.designed.raw_value"><a style="color: #aaaaaa;">Share &amp;amp; Invite</a></t>
- </li>
- <li>
- <t t-if="record.tot_start_survey.raw_value &gt; 0"><a name="action_result_survey" type="object">Analyze Answers</a> <span t-if="record.tot_start_survey.raw_value &gt; 0">(<field name="tot_start_survey" />)</span></t>
- <t t-if="record.tot_start_survey.raw_value &lt;= 0"><a style="color: #aaaaaa;">Analyze Answers</a></t>
- </li>
- </ul>
- </div>
- <div class="o_kanban_record_bottom">
- <div class="oe_kanban_bottom_left"/>
- <div class="oe_kanban_bottom_right">
- <field name="activity_ids" widget="kanban_activity" />
- </div>
- </div>
- </div>
- </templates>
- </kanban>
- </field>
- </record>
- <record id="survey_survey_view_search" model="ir.ui.view">
- <field name="name">survey.survey.search</field>
- <field name="model">survey.survey</field>
- <field name="arch" type="xml">
- <search string="Survey">
- <field string="Title" name="title"/>
- <filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
- <separator/>
- <filter string="My Activities" name="activities_my"
- domain="[('activity_ids.user_id', '=', uid)]"/>
- <separator/>
- <filter string="Late Activities" name="activities_overdue"
- domain="[('activity_ids.date_deadline', '&lt;', 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'))]"/>
- <filter string="Upcoming Activities" name="activities_upcoming_all"
- domain="[('activity_ids.date_deadline', '&gt;', context_today().strftime('%Y-%m-%d'))
- ]"/>
- </search>
- </field>
- </record>
-
- <record model="ir.actions.act_window" id="action_survey_form">
- <field name="name">Surveys</field>
- <field name="res_model">survey.survey</field>
- <field name="view_type">form</field>
- <field name="view_mode">kanban,tree,form,activity</field>
- <field name="help" type="html">
- <p class="o_view_nocontent_smiling_face">
- Add a new survey
- </p><p>
- You can create surveys for different purposes: customer opinion, services feedback, recruitment interviews, employee's periodical evaluations, marketing campaigns, etc.
- </p><p>
- Design easily your survey, send invitations to answer by email and analyse answers.
- </p>
- </field>
- </record>
- <act_window context="{'search_default_survey_id': [active_id], 'default_survey_id': active_id}" id="act_survey_pages" name="Pages" res_model="survey.page" src_model="survey.survey"/>
- <act_window context="{'search_default_survey_id': [active_id], 'default_survey_id': active_id}" id="act_survey_question" name="Questions" res_model="survey.question" src_model="survey.survey"/>
-
- <!-- PAGES -->
- <record model="ir.ui.view" id="survey_page_form">
- <field name="name">Form view for survey page</field>
- <field name="model">survey.page</field>
- <field name="arch" type="xml">
- <form string="Survey Page" create="false">
- <sheet>
- <div class="oe_title" style="width: 100%;">
- <label for="title" class="oe_edit_only"/>
- <h1><field name="title" placeholder="Page Title"/></h1>
- </div>
- <field name="question_ids" nolabel="1" mode="tree" context="{'default_page_id': active_id}">
- <tree>
- <field name="sequence" widget="handle"/>
- <field name="question"/>
- <field name="type"/>
- <field name="constr_mandatory"/>
- </tree>
- </field>
- </sheet>
- </form>
- </field>
- </record>
- <record model="ir.ui.view" id="survey_page_tree">
- <field name="name">Tree view for survey page</field>
- <field name="model">survey.page</field>
- <field name="arch" type="xml">
- <tree string="Survey Page" create="false">
- <field name="sequence" widget="handle"/>
- <field name="title"/>
- <field name="survey_id"/>
- <field name="question_ids" string="#Questions"/>
- </tree>
- </field>
- </record>
- <record model="ir.ui.view" id="survey_page_search" >
- <field name="name">Search view for survey page</field>
- <field name="model">survey.page</field>
- <field name="arch" type="xml">
- <search string="Search Page">
- <field name="title" string="Page"/>
- <field name="survey_id" string="Survey"/>
- <group expand="0" string="Group By">
- <filter name="group_by_survey" string="Survey" domain="[]" context="{'group_by':'survey_id'}"/>
- </group>
- </search>
- </field>
- </record>
-
- <record model="ir.actions.act_window" id="action_survey_page_form">
- <field name="name">Pages</field>
- <field name="res_model">survey.page</field>
- <field name="view_type">form</field>
- <field name="view_mode">tree,form</field>
- <field name="search_view_id" ref="survey_page_search"/>
- <field name="context">{'search_default_group_by_survey': True}</field>
- <field name="help" type="html">
- <p class="o_view_nocontent_empty_folder">
- No pages found
- </p>
- </field>
- </record>
- <act_window context="{'search_default_page_id': active_id, 'default_page_id': active_id}" id="act_survey_page_question" name="Questions" res_model="survey.question" src_model="survey.page"/>
-
- <template id="assets_backend" name="survey assets" inherit_id="web.assets_backend">
- <xpath expr="." position="inside">
- <link rel="stylesheet" href="/survey/static/src/css/survey_result.css"/>
- </xpath>
- </template>
-
- <!-- QUESTIONS -->
- <record model="ir.ui.view" id="survey_question_form">
- <field name="name">Form view for survey question</field>
- <field name="model">survey.question</field>
- <field name="arch" type="xml">
- <form string="Survey Question" create="false">
- <sheet>
- <div class="oe_title" style="width: 100%;">
- <label for="question" string="Question name" class="oe_edit_only"/>
- <h1><field name="question" colspan="4" placeholder="Question Name"/></h1>
- <separator />
- </div>
- <group>
- <group>
- <field name="type" widget="radio" />
- </group>
- <group>
- <div class="col-lg-6 offset-lg-3 o_preview_questions">
- <!-- Multiple Lines Text Zone -->
- <div attrs="{'invisible': [('type', '!=', 'free_text')]}">
- <i class="fa fa-align-justify fa-4x" role="img" aria-label="Multiple lines" title="Multiple Lines"/>
- </div>
- <!-- Single Line Text Zone -->
- <div attrs="{'invisible': [('type', '!=', 'textbox')]}">
- <i class="fa fa-minus fa-4x" role="img" aria-label="Single Line" title="Single Line"/>
- </div>
- <!-- Numerical Value -->
- <div attrs="{'invisible': [('type', '!=', 'numerical_box')]}">
- <i class="fa fa-2x" role="img" aria-label="Numeric" title="Numeric">123..</i>
- </div>
- <!-- Date and Time -->
- <div attrs="{'invisible': [('type', '!=', 'date')]}">
- <p class="o_datetime">YYYY-MM-DD
- <i class="fa fa-calendar fa-2x" role="img" aria-label="Calendar" title="Calendar"/>
- </p>
- </div>
- <!-- Multiple choice: only one answer -->
- <div attrs="{'invisible': [('type', '!=', 'simple_choice')]}" role="img" aria-label="Multiple choice with one answer" title="Multiple choice with one answer">
- <div class="row"><i class="fa fa-circle-o fa-lg"/> answer</div>
- <div class="row"><i class="fa fa-dot-circle-o fa-lg"/> answer</div>
- <div class="row"><i class="fa fa-circle-o fa-lg"/> answer</div>
- </div>
- <!-- Multiple choice: multiple answers allowed -->
- <div attrs="{'invisible': [('type', '!=', 'multiple_choice')]}" role="img" aria-label="Multiple choice with multiple answers" title="Multiple choice with multiple answers">
- <div class="row"><i class="fa fa-square-o fa-lg"/> answer</div>
- <div class="row"><i class="fa fa-check-square-o fa-lg"/> answer</div>
- <div class="row"><i class="fa fa-square-o fa-lg"/> answer</div>
- </div>
- <!-- Matrix -->
- <div attrs="{'invisible': [('type', '!=', 'matrix')]}">
- <div class="row o_matrix_head">
- <div class="col-lg-3"></div>
- <div class="col-lg-3">ans</div>
- <div class="col-lg-3">ans</div>
- <div class="col-lg-3">ans</div>
- </div>
- <div class="row o_matrix_row">
- <div class="col-lg-3">Row1</div>
- <div class="col-lg-3"><i class="fa fa-circle-o fa-lg" role="img" aria-label="Not checked" title="Not checked"/></div>
- <div class="col-lg-3"><i class="fa fa-dot-circle-o fa-lg" role="img" aria-label="Checked" title="Checked"/></div>
- <div class="col-lg-3"><i class="fa fa-circle-o fa-lg" role="img" aria-label="Not checked" title="Not checked"/></div>
- </div>
- <div class="row o_matrix_row">
- <div class="col-lg-3">Row2</div>
- <div class="col-lg-3"><i class="fa fa-circle-o fa-lg" role="img" aria-label="Not checked" title="Not checked"/></div>
- <div class="col-lg-3"><i class="fa fa-circle-o fa-lg" role="img" aria-label="Not checked" title="Not checked"/></div>
- <div class="col-lg-3"><i class="fa fa-dot-circle-o fa-lg" role="img" aria-label="Checked" title="Checked"/></div>
- </div>
- <div class="row o_matrix_row">
- <div class="col-lg-3">Row3</div>
- <div class="col-lg-3"><i class="fa fa-dot-circle-o fa-lg" role="img" aria-label="Checked" title="Checked"/></div>
- <div class="col-lg-3"><i class="fa fa-circle-o fa-lg" role="img" aria-label="Not checked" title="Not checked"/></div>
- <div class="col-lg-3"><i class="fa fa-circle-o fa-lg" role="img" aria-label="Not checked" title="Not checked"/></div>
- </div>
- </div>
- </div>
- </group>
- </group>
- <notebook>
- <page string="Answers">
- <field name="validation_email" attrs="{'invisible': [('type', '!=', 'textbox')]}"/>
- <label for="validation_email" attrs="{'invisible': [('type', '!=', 'textbox')]}"/>
- <separator />
- <field name="labels_ids" string="Type of answers" context="{'default_question_id': active_id}" attrs="{'invisible': [('type', 'not in', ['simple_choice', 'multiple_choice', 'matrix'])]}">
- <tree editable="bottom">
- <field name="sequence" widget="handle"/>
- <field name="value" string="Choices"/>
- <field name="quizz_mark" groups="base.group_no_one"/>
- </tree>
- </field>
- <separator />
- <label for="labels_ids_2" attrs="{'invisible': [('type', '!=', 'matrix')]}" />
- <field name="labels_ids_2" context="{'default_question_id_2': active_id}" attrs="{'invisible': [('type', '!=', 'matrix')]}">
- <tree editable="bottom">
- <field name="sequence" widget="handle"/>
- <field name="value" string="Rows"/>
- </tree>
- </field>
- </page>
- <page string="Options">
- <group string="Constraints">
- <group colspan="2" col="4">
- <field name="constr_mandatory" string="Mandatory Answer"/>
- <field name="constr_error_msg" attrs="{'invisible': [('constr_mandatory', '=', False)]}"/>
- </group>
- <div colspan="2" attrs="{'invisible': [('type', 'not in', ['textbox', 'numerical_box', 'date'])]}">
- <group>
- <field name="validation_required" attrs="{'invisible': [('validation_email', '=', True), ('type', '=', 'textbox')]}"/>
- </group>
- <group col="4" attrs="{'invisible': [('validation_required', '=', False)]}">
- <field name="validation_length_min" attrs="{'invisible': [('type', '!=', 'textbox')]}"/>
- <field name="validation_length_max" attrs="{'invisible': [('type', '!=', 'textbox')]}"/>
- <field name="validation_min_float_value" attrs="{'invisible': [('type', '!=', 'numerical_box')]}"/>
- <field name="validation_max_float_value" attrs="{'invisible': [('type', '!=', 'numerical_box')]}"/>
- <field name="validation_min_date" attrs="{'invisible': [('type', '!=', 'date')]}"/>
- <field name="validation_max_date" attrs="{'invisible': [('type', '!=', 'date')]}"/>
- <field name="validation_error_msg" colspan="4"/>
- </group>
- </div>
- <group>
- <field name="matrix_subtype" attrs="{'invisible':[('type','not in',['matrix'])]}"/>
- </group>
- </group>
- <group string="Display mode" attrs="{'invisible':[('type','not in',['simple_choice', 'multiple_choice'])]}">
- <field name="display_mode" string="Format" attrs="{'invisible':[('type','not in',['simple_choice'])],'required':[('type','=','simple_choice')]}"/>
- <field name="column_nb" string="Number of columns" attrs="{'invisible':[('display_mode','=','dropdown'), ('type','=','simple_choice')]}"/>
- </group>
- <group string="Allow Comments" attrs="{'invisible':[('type','not in',['simple_choice','multiple_choice', 'matrix'])]}">
- <field name='comments_allowed' />
- <field name='comments_message' attrs="{'invisible': [('comments_allowed', '=', False)]}"/>
- <field name='comment_count_as_answer' attrs="{'invisible': ['|', ('comments_allowed', '=', False), ('type', 'in', ['matrix'])]}" />
- </group>
- </page>
- </notebook>
- </sheet>
- </form>
- </field>
- </record>
- <record model="ir.ui.view" id="survey_question_tree">
- <field name="name">Tree view for survey question</field>
- <field name="model">survey.question</field>
- <field name="arch" type="xml">
- <tree string="Survey Question" create="false">
- <field name="sequence" widget="handle"/>
- <field name="question"/>
- <field name="page_id"/>
- <field name="survey_id"/>
- <field name="type"/>
- </tree>
- </field>
- </record>
- <record model="ir.ui.view" id="survey_question_search">
- <field name="name">Search view for survey question</field>
- <field name="model">survey.question</field>
- <field name="arch" type="xml">
- <search string="Search Question">
- <field name="question" string="Question"/>
- <field name="survey_id" string="Survey"/>
- <field name="page_id" string="Page"/>
- <field name="type" string="Type"/>
- <group expand="1" string="Group By">
- <filter name="group_by_page" string="Page" domain="[]" context="{'group_by':'page_id'}"/>
- <filter name="group_by_type" string="Type" domain="[]" context="{'group_by':'type'}"/>
- </group>
- </search>
- </field>
- </record>
-
- <record model="ir.actions.act_window" id="action_survey_question_form">
- <field name="name">Questions</field>
- <field name="res_model">survey.question</field>
- <field name="view_type">form</field>
- <field name="view_mode">tree,form</field>
- <field name="search_view_id" ref="survey_question_search"/>
- <field name="context">{'search_default_group_by_page': True}</field>
- <field name="help" type="html">
- <p class="o_view_nocontent_empty_folder">
- No questions found
- </p>
- </field>
- </record>
-
- <!-- LABELS -->
- <record model="ir.ui.view" id="survey_label_tree">
- <field name="name">survey_label_tree</field>
- <field name="model">survey.label</field>
- <field name="arch" type="xml">
- <tree string="Survey Label" create="false">
- <field name="sequence" widget="handle"/>
- <field name="question_id"/>
- <field name="question_id_2"/>
- <field name="value"/>
- <field name="quizz_mark" groups="base.group_no_one"/>
- </tree>
- </field>
- </record>
- <record id="survey_label_search" model="ir.ui.view">
- <field name="name">survey_label_search</field>
- <field name="model">survey.label</field>
- <field name="arch" type="xml">
- <search string="Search Label">
- <field name="question_id" string="Question"/>
- <filter name="group_by_question" string="Question" domain="[]" context="{'group_by':'question_id'}"/>
- </search>
- </field>
- </record>
-
- <record model="ir.actions.act_window" id="action_survey_label_form">
- <field name="name">Labels</field>
- <field name="res_model">survey.label</field>
- <field name="view_type">form</field>
- <field name="view_mode">tree,form</field>
- <field name="search_view_id" ref="survey_label_search"/>
- <field name="context">{'search_default_group_by_question': True}</field>
- <field name="help" type="html">
- <p class="o_view_nocontent_empty_folder">
- No survey labels found
- </p>
- </field>
- </record>
-
- <!-- USER INPUT LINES
- .. note:: these views are useful mainly for technical users/administrators -->
- <record model="ir.ui.view" id="survey_user_input_line_form">
- <field name="name">survey_user_input_line_form</field>
- <field name="model">survey.user_input_line</field>
- <field name="arch" type="xml">
- <form string="User input line details" create="false">
- <sheet>
- <group col="4">
- <field name="question_id"/>
- <field name="date_create"/>
- <field name="answer_type"/>
- <field name="skipped" />
- <field name="quizz_mark" groups="base.group_no_one"/>
- </group>
- <group>
- <field name="value_text" colspan='2' attrs="{'invisible': [('answer_type','!=','text')]}"/>
- <field name="value_number" colspan='2' attrs="{'invisible': [('answer_type','!=','number')]}"/>
- <field name="value_date" colspan='2' attrs="{'invisible': [('answer_type','!=','date')]}"/>
- <field name="value_free_text" colspan='2' attrs="{'invisible': [('answer_type','!=','free_text')]}"/>
- <field name="value_suggested_row" colspan='2' />
- <field name="value_suggested" colspan='2' attrs="{'invisible': [('answer_type','!=','suggestion')]}"/>
- </group>
- </sheet>
- </form>
- </field>
- </record>
- <record model="ir.ui.view" id="survey_response_line_tree">
- <field name="name">survey_response_line_tree</field>
- <field name="model">survey.user_input_line</field>
- <field name="arch" type="xml">
- <tree string="Survey Answer Line" create="false">
- <field name="survey_id"/>
- <field name="user_input_id"/>
- <field name="page_id"/>
- <field name="question_id"/>
- <field name="date_create"/>
- <field name="answer_type"/>
- <field name="skipped"/>
- <field name="quizz_mark" groups="base.group_no_one"/>
- </tree>
- </field>
- </record>
- <record id="survey_response_line_search" model="ir.ui.view">
- <field name="name">survey_response_line_search</field>
- <field name="model">survey.user_input_line</field>
- <field name="arch" type="xml">
- <search string="Search User input lines">
- <field name="user_input_id"/>
- <field name="survey_id"/>
- <group expand="1" string="Group By">
- <filter name="group_by_survey" string="Survey" domain="[]" context="{'group_by':'survey_id'}"/>
- <filter name="group_by_user_input" string="User Input" domain="[]" context="{'group_by':'user_input_id'}"/>
- </group>
- </search>
- </field>
- </record>
-
- <record model="ir.actions.act_window" id="action_survey_user_input_line">
- <field name="name">Survey User Input lines</field>
- <field name="res_model">survey.user_input_line</field>
- <field name="view_mode">tree,form</field>
- <field name="search_view_id" ref="survey_response_line_search"/>
- <field name="context">{'search_default_group_by_survey': True, 'search_default_group_by_user_input': True}</field>
- <field name="help" type="html">
- <p class="o_view_nocontent_empty_folder">
- No user input lines found
- </p>
- </field>
- </record>
-
-
- <record model="ir.ui.view" id="survey_stage_form">
- <field name="name">Form view for survey stage</field>
- <field name="model">survey.stage</field>
- <field name="arch" type="xml">
- <form string="Stage">
- <group col='4' colspan='4'>
- <field name="name" />
- <field name="closed" />
- <field name="sequence" />
- <field name="fold" />
- </group>
- </form>
- </field>
- </record>
-
- <record id="survey_action_server_clean_test_answers" model="ir.actions.server">
- <field name="name">Survey: Clean test answers</field>
- <field name="type">ir.actions.server</field>
- <field name="model_id" ref="model_survey_survey" />
- <field name="binding_model_id" ref="model_survey_survey" />
- <field name="state">code</field>
- <field name="code">
-if records:
- env['survey.user_input'].search([('survey_id', 'in', records.ids), ('test_entry', '=', 'True')]).unlink()
- </field>
- </record>
-
- <!-- MENU ELEMENTS -->
-
- <!-- Left menu categories-->
- <menuitem name="Surveys" id="menu_surveys" sequence="70" groups="group_survey_user" web_icon="survey,static/description/icon.png"/>
-
- <!-- Left menu elements: Survey -->
- <menuitem name="Surveys" id="menu_survey_form" action="action_survey_form" parent="menu_surveys" sequence="1"/>
- <menuitem name="Answers" id="menu_survey_type_form1" action="action_survey_user_input" parent="menu_surveys" sequence="1"/>
-
-
- <!-- Left menu elements: Technical Configuration -->
- <menuitem name="Configuration" id="menu_surveys_configuration" parent="menu_surveys" groups="base.group_no_one" sequence="100" />
- <menuitem name="Pages" id="menu_survey_page_form1" action="action_survey_page_form" parent="menu_surveys_configuration" sequence="1"/>
- <menuitem name="Questions" id="menu_survey_question_form1" action="action_survey_question_form" parent="menu_surveys_configuration" sequence="2"/>
- <menuitem name="Labels" id="menu_survey_label_form1" action="action_survey_label_form" parent="menu_surveys_configuration" sequence="3"/>
- <menuitem name="User Input Lines" id="menu_survey_response_line_form" action="action_survey_user_input_line" parent="menu_surveys_configuration" sequence="4"/>
- </data>
-</odoo>
diff --git a/addons/survey/wizard/__init__.py b/addons/survey/wizard/__init__.py
index 4b354293447..09c05c0941a 100644
--- a/addons/survey/wizard/__init__.py
+++ b/addons/survey/wizard/__init__.py
@@ -1,4 +1,4 @@
# -*- encoding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from . import survey_email_compose_message
+from . import survey_mail_compose_message
diff --git a/addons/survey/wizard/survey_email_compose_message.py b/addons/survey/wizard/survey_mail_compose_message.py
index f57cc226b6c..1f1ab22a77e 100644
--- a/addons/survey/wizard/survey_email_compose_message.py
+++ b/addons/survey/wizard/survey_mail_compose_message.py
@@ -150,7 +150,7 @@ class SurveyMailComposeMessage(models.TransientModel):
'survey_id': wizard.survey_id.id,
'deadline': wizard.date_deadline,
'date_create': fields.Datetime.now(),
- 'type': 'link',
+ 'input_type': 'link',
'state': 'new',
'token': token,
'partner_id': partner_id,
diff --git a/addons/survey/wizard/survey_email_compose_message.xml b/addons/survey/wizard/survey_mail_compose_message_views.xml
index 167464896ba..167464896ba 100644
--- a/addons/survey/wizard/survey_email_compose_message.xml
+++ b/addons/survey/wizard/survey_mail_compose_message_views.xml
diff --git a/addons/test_mail/models/test_mail_models.py b/addons/test_mail/models/test_mail_models.py
index 8ada6ed86c8..5ba9ff195e8 100644
--- a/addons/test_mail/models/test_mail_models.py
+++ b/addons/test_mail/models/test_mail_models.py
@@ -78,7 +78,7 @@ class MailTestFull(models.Model):
def _track_subtype(self, init_values):
self.ensure_one()
if 'umbrella_id' in init_values and self.umbrella_id:
- return 'test_mail.st_mail_test_full_umbrella_upd'
+ return self.env.ref('test_mail.st_mail_test_full_umbrella_upd')
return super(MailTestFull, self)._track_subtype(init_values)
diff --git a/addons/test_mail/tests/test_message_track.py b/addons/test_mail/tests/test_message_track.py
index 25cd5a7a3c3..c485cd3fe0f 100644
--- a/addons/test_mail/tests/test_message_track.py
+++ b/addons/test_mail/tests/test_message_track.py
@@ -18,8 +18,8 @@ class TestTracking(common.BaseFunctionalTest, common.MockEmails):
elif value_type in ('many2one'):
self.assertEqual(tracking.old_value_integer, old_value and old_value.id or False)
self.assertEqual(tracking.new_value_integer, new_value and new_value.id or False)
- self.assertEqual(tracking.old_value_char, old_value and old_value.name_get()[0][1] or '')
- self.assertEqual(tracking.new_value_char, new_value and new_value.name_get()[0][1] or '')
+ self.assertEqual(tracking.old_value_char, old_value and old_value.display_name or '')
+ self.assertEqual(tracking.new_value_char, new_value and new_value.display_name or '')
else:
self.assertEqual(1, 0)
diff --git a/addons/test_mass_mailing/tests/test_mass_mailing.py b/addons/test_mass_mailing/tests/test_mass_mailing.py
index 6629be54f9f..c18d19a6bea 100644
--- a/addons/test_mass_mailing/tests/test_mass_mailing.py
+++ b/addons/test_mass_mailing/tests/test_mass_mailing.py
@@ -138,7 +138,6 @@ class TestAccessRights(TransactionCase):
'body_html': 'This is mass mail marketing demo'})
self.mass_mailing.contact_list_ids = [mailing_list_1.id]
self.mass_mailing.put_in_queue()
- res_ids = self.mass_mailing.get_remaining_recipients()
composer_values = {
'body': self.mass_mailing.convert_links()[self.mass_mailing.id],
'subject': self.mass_mailing.name,
@@ -149,7 +148,7 @@ class TestAccessRights(TransactionCase):
'mailing_list_ids': [(4, l.id) for l in self.mass_mailing.contact_list_ids],
}
composer = self.env['mail.compose.message'].with_context(
- active_ids=res_ids,
+ active_ids=[mailing_contact_1.id, mailing_contact_2.id],
mass_mailing_seen_list=self.mass_mailing._get_seen_list(),
mass_mailing_opt_out_list=self.mass_mailing._get_opt_out_list()).create(composer_values)
composer.send_mail()
@@ -194,7 +193,6 @@ class TestAccessRights(TransactionCase):
'body_html': 'This is mass mail marketing demo'})
self.mass_mailing.contact_list_ids = [mailing_list_1.id, mailing_list_2.id]
self.mass_mailing.put_in_queue()
- res_ids = self.mass_mailing.get_remaining_recipients()
composer_values = {
'body': self.mass_mailing.convert_links()[self.mass_mailing.id],
'subject': self.mass_mailing.name,
@@ -205,7 +203,7 @@ class TestAccessRights(TransactionCase):
'mailing_list_ids': [(4, l.id) for l in self.mass_mailing.contact_list_ids],
}
composer = self.env['mail.compose.message'].with_context(
- active_ids=res_ids,
+ active_ids=[mailing_contact_1.id, mailing_contact_2.id],
mass_mailing_seen_list=self.mass_mailing._get_seen_list(),
mass_mailing_opt_out_list=self.mass_mailing._get_opt_out_list()).create(composer_values)
composer.send_mail()
@@ -244,7 +242,6 @@ class TestAccessRights(TransactionCase):
'body_html': 'This is mass mail marketing demo'})
self.mass_mailing.contact_list_ids = [mailing_list_1.id]
self.mass_mailing.put_in_queue()
- res_ids = self.mass_mailing.get_remaining_recipients()
composer_values = {
'body': self.mass_mailing.convert_links()[self.mass_mailing.id],
'subject': self.mass_mailing.name,
@@ -255,7 +252,7 @@ class TestAccessRights(TransactionCase):
'mailing_list_ids': [(4, l.id) for l in self.mass_mailing.contact_list_ids],
}
composer = self.env['mail.compose.message'].with_context(
- active_ids=res_ids,
+ active_ids=[mailing_contact_1.id, mailing_contact_2.id],
mass_mailing_seen_list=self.mass_mailing._get_seen_list(),
mass_mailing_opt_out_list=self.mass_mailing._get_opt_out_list()).create(composer_values)
composer.send_mail()
diff --git a/addons/utm/__init__.py b/addons/utm/__init__.py
index a0fdc10fe11..dc5e6b693d1 100644
--- a/addons/utm/__init__.py
+++ b/addons/utm/__init__.py
@@ -1,2 +1,4 @@
# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
from . import models
diff --git a/addons/utm/__manifest__.py b/addons/utm/__manifest__.py
index 66fb0775f94..ffa06da03bf 100644
--- a/addons/utm/__manifest__.py
+++ b/addons/utm/__manifest__.py
@@ -1,16 +1,18 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
{
'name': 'UTM Trackers',
'category': 'Hidden',
'description': """
-Enable UTM trackers in shared links.
-=====================================================
- """,
+Enable management of UTM trackers: campaign, medium, source.
+""",
'version': '1.0',
'depends': ['base'],
'data': [
+ 'data/utm_data.xml',
+ 'views/utm_views.xml',
'security/ir.model.access.csv',
- 'views/utm.xml',
- 'data/utm_data.xml'
],
'demo': [],
'auto_install': False,
diff --git a/addons/utm/models/__init__.py b/addons/utm/models/__init__.py
index faef4eabef7..6ebdc46b951 100644
--- a/addons/utm/models/__init__.py
+++ b/addons/utm/models/__init__.py
@@ -1,3 +1,6 @@
# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
from . import utm
+from . import utm_mixin
from . import ir_http
diff --git a/addons/utm/models/utm.py b/addons/utm/models/utm.py
index 0627177ffd2..41efca78cf5 100644
--- a/addons/utm/models/utm.py
+++ b/addons/utm/models/utm.py
@@ -1,9 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from odoo import api, fields, models, SUPERUSER_ID
-from odoo.http import request
-from odoo.tools import pycompat
+from odoo import fields, models
class UtmMedium(models.Model):
@@ -29,57 +27,3 @@ class UtmSource(models.Model):
_description = 'UTM Source'
name = fields.Char(string='Source Name', required=True, translate=True)
-
-
-class UtmMixin(models.AbstractModel):
-
- """Mixin class for objects which can be tracked by marketing. """
- _name = 'utm.mixin'
- _description = 'UTM Mixin'
-
- campaign_id = fields.Many2one('utm.campaign', 'Campaign',
- help="This is a name that helps you keep track of your different campaign efforts, e.g. Fall_Drive, Christmas_Special")
- source_id = fields.Many2one('utm.source', 'Source',
- help="This is the source of the link, e.g. Search Engine, another domain, or name of email list")
- medium_id = fields.Many2one('utm.medium', 'Medium',
- help="This is the method of delivery, e.g. Postcard, Email, or Banner Ad", oldname='channel_id')
-
- def tracking_fields(self):
- # This function cannot be overridden in a model which inherit utm.mixin
- # Limitation by the heritage on AbstractModel
- # record_crm_lead.tracking_fields() will call tracking_fields() from module utm.mixin (if not overridden on crm.lead)
- # instead of the overridden method from utm.mixin.
- # To force the call of overridden method, we use self.env['utm.mixin'].tracking_fields() which respects overridden
- # methods of utm.mixin, but will ignore overridden method on crm.lead
- return [
- # ("URL_PARAMETER", "FIELD_NAME_MIXIN", "NAME_IN_COOKIES")
- ('utm_campaign', 'campaign_id', 'odoo_utm_campaign'),
- ('utm_source', 'source_id', 'odoo_utm_source'),
- ('utm_medium', 'medium_id', 'odoo_utm_medium'),
- ]
-
- @api.model
- def default_get(self, fields):
- values = super(UtmMixin, self).default_get(fields)
-
- # We ignore UTM for salemen, except some requests that could be done as superuser_id to bypass access rights.
- if self.env.uid != SUPERUSER_ID and self.env.user.has_group('sales_team.group_sale_salesman'):
- return values
-
- for url_param, field_name, cookie_name in self.env['utm.mixin'].tracking_fields():
- if field_name in fields:
- field = self._fields[field_name]
- value = False
- if request:
- # ir_http dispatch saves the url params in a cookie
- value = request.httprequest.cookies.get(cookie_name)
- # if we receive a string for a many2one, we search/create the id
- if field.type == 'many2one' and isinstance(value, pycompat.string_types) and value:
- Model = self.env[field.comodel_name]
- records = Model.search([('name', '=', value)], limit=1)
- if not records:
- records = Model.create({'name': value})
- value = records.id
- if value:
- values[field_name] = value
- return values
diff --git a/addons/utm/models/utm_mixin.py b/addons/utm/models/utm_mixin.py
new file mode 100644
index 00000000000..c239501c972
--- /dev/null
+++ b/addons/utm/models/utm_mixin.py
@@ -0,0 +1,59 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models, SUPERUSER_ID
+from odoo.http import request
+from odoo.tools import pycompat
+
+
+class UtmMixin(models.AbstractModel):
+ """ Mixin class for objects which can be tracked by marketing. """
+ _name = 'utm.mixin'
+ _description = 'UTM Mixin'
+
+ campaign_id = fields.Many2one('utm.campaign', 'Campaign',
+ help="This is a name that helps you keep track of your different campaign efforts, e.g. Fall_Drive, Christmas_Special")
+ source_id = fields.Many2one('utm.source', 'Source',
+ help="This is the source of the link, e.g. Search Engine, another domain, or name of email list")
+ medium_id = fields.Many2one('utm.medium', 'Medium',
+ help="This is the method of delivery, e.g. Postcard, Email, or Banner Ad", oldname='channel_id')
+
+ @api.model
+ def default_get(self, fields):
+ values = super(UtmMixin, self).default_get(fields)
+
+ # We ignore UTM for salemen, except some requests that could be done as superuser_id to bypass access rights.
+ if self.env.uid != SUPERUSER_ID and self.env.user.has_group('sales_team.group_sale_salesman'):
+ return values
+
+ for url_param, field_name, cookie_name in self.env['utm.mixin'].tracking_fields():
+ if field_name in fields:
+ field = self._fields[field_name]
+ value = False
+ if request:
+ # ir_http dispatch saves the url params in a cookie
+ value = request.httprequest.cookies.get(cookie_name)
+ # if we receive a string for a many2one, we search/create the id
+ if field.type == 'many2one' and isinstance(value, pycompat.string_types) and value:
+ Model = self.env[field.comodel_name]
+ records = Model.search([('name', '=', value)], limit=1)
+ if not records:
+ records = Model.create({'name': value})
+ value = records.id
+ if value:
+ values[field_name] = value
+ return values
+
+ def tracking_fields(self):
+ # This function cannot be overridden in a model which inherit utm.mixin
+ # Limitation by the heritage on AbstractModel
+ # record_crm_lead.tracking_fields() will call tracking_fields() from module utm.mixin (if not overridden on crm.lead)
+ # instead of the overridden method from utm.mixin.
+ # To force the call of overridden method, we use self.env['utm.mixin'].tracking_fields() which respects overridden
+ # methods of utm.mixin, but will ignore overridden method on crm.lead
+ return [
+ # ("URL_PARAMETER", "FIELD_NAME_MIXIN", "NAME_IN_COOKIES")
+ ('utm_campaign', 'campaign_id', 'odoo_utm_campaign'),
+ ('utm_source', 'source_id', 'odoo_utm_source'),
+ ('utm_medium', 'medium_id', 'odoo_utm_medium'),
+ ]
diff --git a/addons/utm/views/utm.xml b/addons/utm/views/utm.xml
deleted file mode 100644
index 6e66bf89103..00000000000
--- a/addons/utm/views/utm.xml
+++ /dev/null
@@ -1,110 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<odoo>
- <menuitem id="menu_link_tracker_root" name="Link Tracker" groups="base.group_no_one" sequence="30" web_icon="utm,static/description/icon.png"/>
- <menuitem name="UTMs" id="marketing_utm" parent="menu_link_tracker_root" sequence="99" groups="base.group_no_one"/>
-
- <!-- utm.campaign -->
- <record model="ir.ui.view" id="utm_campaign_tree">
- <field name="name">utm.campaign.tree</field>
- <field name="model">utm.campaign</field>
- <field name="arch" type="xml">
- <tree string="Campaign">
- <field name="name"/>
- </tree>
- </field>
- </record>
-
- <record model="ir.ui.view" id="utm_campaign_form">
- <field name="name">utm.campaign.form</field>
- <field name="model">utm.campaign</field>
- <field name="arch" type="xml">
- <form string="Campaign">
- <group>
- <field name="name"/>
- </group>
- </form>
- </field>
- </record>
-
- <record id="utm_campaign_act" model="ir.actions.act_window">
- <field name="name">Campaigns</field>
- <field name="res_model">utm.campaign</field>
- <field name="view_type">form</field>
- <field name="view_id" ref="utm_campaign_tree"/>
- </record>
-
- <menuitem action="utm_campaign_act" id="menu_utm_campaign_act" sequence="4"
- groups="base.group_no_one" parent="marketing_utm"/>
-
- <!-- utm.medium -->
- <record id="utm_medium_view_tree" model="ir.ui.view">
- <field name="name">utm.medium.tree</field>
- <field name="model">utm.medium</field>
- <field name="arch" type="xml">
- <tree string="Medium">
- <field name="name"/>
- <field name="active"/>
- </tree>
- </field>
- </record>
- <record id="utm_medium_view_form" model="ir.ui.view">
- <field name="name">utm.medium.form</field>
- <field name="model">utm.medium</field>
- <field name="arch" type="xml">
- <form string="Channel">
- <group>
- <field name="name"/>
- <field name="active"/>
- </group>
- </form>
- </field>
- </record>
- <record id="utm_medium_action" model="ir.actions.act_window">
- <field name="name">Medium</field>
- <field name="res_model">utm.medium</field>
- <field name="view_type">form</field>
- <field name="view_mode">tree,form</field>
- <field name="help" type="html">
- <p class="o_view_nocontent_smiling_face">
- Define a new medium
- </p>
- </field>
- </record>
-
- <menuitem action="utm_medium_action" id="menu_utm_medium" parent="marketing_utm" sequence="4" groups="base.group_no_one"/>
-
- <!-- utm.source -->
- <record id="utm_source_view_tree" model="ir.ui.view">
- <field name="name">utm.source.tree</field>
- <field name="model">utm.source</field>
- <field name="arch" type="xml">
- <tree string="Medium">
- <field name="name"/>
- </tree>
- </field>
- </record>
- <record id="utm_source_view_form" model="ir.ui.view">
- <field name="name">utm.source.form</field>
- <field name="model">utm.source</field>
- <field name="arch" type="xml">
- <form string="Channel">
- <group>
- <field name="name"/>
- </group>
- </form>
- </field>
- </record>
- <record id="utm_source_action" model="ir.actions.act_window">
- <field name="name">Sources</field>
- <field name="res_model">utm.source</field>
- <field name="view_type">form</field>
- <field name="view_mode">tree,form</field>
- <field name="help" type="html">
- <p class="o_view_nocontent_smiling_face">
- Define a new source
- </p>
- </field>
- </record>
-
- <menuitem action="utm_source_action" id="menu_utm_source" parent="marketing_utm" sequence="4" groups="base.group_no_one"/>
-</odoo>
diff --git a/addons/utm/views/utm_views.xml b/addons/utm/views/utm_views.xml
new file mode 100644
index 00000000000..18852e1c270
--- /dev/null
+++ b/addons/utm/views/utm_views.xml
@@ -0,0 +1,136 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <menuitem id="menu_link_tracker_root"
+ name="Link Tracker"
+ sequence="30"
+ web_icon="utm,static/description/icon.png"
+ groups="base.group_no_one"/>
+ <menuitem id="marketing_utm"
+ name="UTMs"
+ parent="menu_link_tracker_root"
+ sequence="99"
+ groups="base.group_no_one"/>
+
+ <!-- utm.campaign -->
+ <record id="utm_campaign_view_tree" model="ir.ui.view">
+ <field name="name">utm.campaign.view.tree</field>
+ <field name="model">utm.campaign</field>
+ <field name="arch" type="xml">
+ <tree string="Campaign">
+ <field name="name"/>
+ </tree>
+ </field>
+ </record>
+ <record id="utm_campaign_view_form" model="ir.ui.view">
+ <field name="name">utm.campaign.view.form</field>
+ <field name="model">utm.campaign</field>
+ <field name="arch" type="xml">
+ <form string="Campaign">
+ <group>
+ <field name="name"/>
+ </group>
+ </form>
+ </field>
+ </record>
+
+ <record id="utm_campaign_action" model="ir.actions.act_window">
+ <field name="name">Campaigns</field>
+ <field name="res_model">utm.campaign</field>
+ <field name="view_type">form</field>
+ <field name="view_id" ref="utm_campaign_view_tree"/>
+ <field name="help" type="html">
+ <p class="o_view_nocontent_smiling_face">
+ Define a new UTM campaign
+ </p>
+ </field>
+ </record>
+
+ <menuitem id="menu_utm_campaign_act"
+ action="utm_campaign_action"
+ parent="marketing_utm"
+ sequence="1"
+ groups="base.group_no_one"/>
+
+ <!-- utm.medium -->
+ <record id="utm_medium_view_tree" model="ir.ui.view">
+ <field name="name">utm.medium.view.tree</field>
+ <field name="model">utm.medium</field>
+ <field name="arch" type="xml">
+ <tree string="Mediums">
+ <field name="name"/>
+ <field name="active"/>
+ </tree>
+ </field>
+ </record>
+ <record id="utm_medium_view_form" model="ir.ui.view">
+ <field name="name">utm.medium.view.form</field>
+ <field name="model">utm.medium</field>
+ <field name="arch" type="xml">
+ <form string="Medium">
+ <group>
+ <field name="name"/>
+ <field name="active"/>
+ </group>
+ </form>
+ </field>
+ </record>
+
+ <record id="utm_medium_action" model="ir.actions.act_window">
+ <field name="name">Mediums</field>
+ <field name="res_model">utm.medium</field>
+ <field name="view_type">form</field>
+ <field name="view_mode">tree,form</field>
+ <field name="help" type="html">
+ <p class="o_view_nocontent_smiling_face">
+ Define a new UTM medium
+ </p>
+ </field>
+ </record>
+
+ <menuitem id="menu_utm_medium"
+ action="utm_medium_action"
+ parent="marketing_utm"
+ sequence="5"
+ groups="base.group_no_one"/>
+
+ <!-- utm.source -->
+ <record id="utm_source_view_tree" model="ir.ui.view">
+ <field name="name">utm.source.view.tree</field>
+ <field name="model">utm.source</field>
+ <field name="arch" type="xml">
+ <tree string="Source">
+ <field name="name"/>
+ </tree>
+ </field>
+ </record>
+ <record id="utm_source_view_form" model="ir.ui.view">
+ <field name="name">utm.source.view.form</field>
+ <field name="model">utm.source</field>
+ <field name="arch" type="xml">
+ <form string="Source">
+ <group>
+ <field name="name"/>
+ </group>
+ </form>
+ </field>
+ </record>
+
+ <record id="utm_source_action" model="ir.actions.act_window">
+ <field name="name">Sources</field>
+ <field name="res_model">utm.source</field>
+ <field name="view_type">form</field>
+ <field name="view_mode">tree,form</field>
+ <field name="help" type="html">
+ <p class="o_view_nocontent_smiling_face">
+ Define a new UTM source
+ </p>
+ </field>
+ </record>
+
+ <menuitem id="menu_utm_source"
+ action="utm_source_action"
+ parent="marketing_utm"
+ sequence="10"
+ groups="base.group_no_one"/>
+
+</odoo>
diff --git a/addons/web/static/lib/es5-shim/README.md b/addons/web/static/lib/es5-shim/README.md
deleted file mode 100644
index abe7abe2857..00000000000
--- a/addons/web/static/lib/es5-shim/README.md
+++ /dev/null
@@ -1,155 +0,0 @@
-`es5-shim.js` and `es5-shim.min.js` monkey-patch a JavaScript context to
-contain all EcmaScript 5 methods that can be faithfully emulated with a
-legacy JavaScript engine.
-
-`es5-sham.js` and `es5-sham.min.js` monkey-patch other ES5 methods as
-closely as possible. For these methods, as closely as possible to ES5
-is not very close. Many of these shams are intended only to allow code
-to be written to ES5 without causing run-time errors in older engines.
-In many cases, this means that these shams cause many ES5 methods to
-silently fail. Decide carefully whether this is what you want.
-
-
-## Tests
-
-The tests are written with the Jasmine BDD test framework.
-To run the tests, navigate to <root-folder>/tests/.
-
-In order to run against the shim-code, the tests attempt to kill the current
-implementation of the missing methods. This happens in <root-folder>/tests/helpers/h-kill.js.
-So in order to run the tests against the built-in methods, invalidate that file somehow
-(comment-out, delete the file, delete the script-tag, etc.).
-
-## Shims
-
-### Complete tests ###
-
-* Array.prototype.every
-* Array.prototype.filter
-* Array.prototype.forEach
-* Array.prototype.indexOf
-* Array.prototype.lastIndexOf
-* Array.prototype.map
-* Array.prototype.some
-* Array.prototype.reduce
-* Array.prototype.reduceRight
-* Array.isArray
-* Date.now
-* Date.prototype.toJSON
-* Function.prototype.bind
- * :warning: Caveat: the bound function's length is always 0.
- * :warning: Caveat: the bound function has a prototype property.
- * :warning: Caveat: bound functions do not try too hard to keep you
- from manipulating their ``arguments`` and ``caller`` properties.
- * :warning: Caveat: bound functions don't have checks in ``call`` and
- ``apply`` to avoid executing as a constructor.
-* Number.prototype.toFixed
-* Object.keys
-* String.prototype.split
-* String.prototype.trim
-* Date.parse (for ISO parsing)
-* Date.prototype.toISOString
-
-## Shams
-
-* :warning: Object.create
-
- For the case of simply "begetting" an object that inherits
- prototypically from another, this should work fine across legacy
- engines.
-
- :warning: Object.create(null) will work only in browsers that
- support prototype assignment. This creates an object that does not
- have any properties inherited from Object.prototype. It will
- silently fail otherwise.
-
- :warning: The second argument is passed to Object.defineProperties
- which will probably fail either silently or with extreme predudice.
-
-* :warning: Object.getPrototypeOf
-
- This will return "undefined" in some cases. It uses `__proto__` if
- it's available. Failing that, it uses constructor.prototype, which
- depends on the constructor property of the object's prototype having
- not been replaced. If your object was created like this, it won't
- work:
-
- function Foo() {
- }
- Foo.prototype = {};
-
- Because the prototype reassignment destroys the constructor
- property.
-
- This will work for all objects that were created using
- `Object.create` implemented with this library.
-
-* :warning: Object.getOwnPropertyNames
-
- This method uses Object.keys, so it will not be accurate on legacy
- engines.
-
-* Object.isSealed
-
- Returns "false" in all legacy engines for all objects, which is
- conveniently guaranteed to be accurate.
-
-* Object.isFrozen
-
- Returns "false" in all legacy engines for all objects, which is
- conveniently guaranteed to be accurate.
-
-* Object.isExtensible
-
- Works like a charm, by trying very hard to extend the object then
- redacting the extension.
-
-### May fail
-
-* :warning: Object.getOwnPropertyDescriptor
-
- The behavior of this shim does not conform to ES5. It should
- probably not be used at this time, until its behavior has been
- reviewed and been confirmed to be useful in legacy engines.
-
-* :warning: Object.defineProperty
-
- In the worst of circumstances, IE 8 provides a version of this
- method that only works on DOM objects. This sham will not be
- installed. The given version of `defineProperty` will throw an
- exception if used on non-DOM objects.
-
- In slightly better circumstances, this method will silently fail to
- set "writable", "enumerable", and "configurable" properties.
-
- Providing a getter or setter with "get" or "set" on a descriptor
- will silently fail on engines that lack "__defineGetter__" and
- "__defineSetter__", which include all versions of IE.
-
- https://github.com/kriskowal/es5-shim/issues#issue/5
-
-* :warning: Object.defineProperties
-
- This uses the Object.defineProperty shim
-
-* Object.seal
-
- Silently fails on all legacy engines. This should be
- fine unless you are depending on the safety and security
- provisions of this method, which you cannot possibly
- obtain in legacy engines.
-
-* Object.freeze
-
- Silently fails on all legacy engines. This should be
- fine unless you are depending on the safety and security
- provisions of this method, which you cannot possibly
- obtain in legacy engines.
-
-* Object.preventExtensions
-
- Silently fails on all legacy engines. This should be
- fine unless you are depending on the safety and security
- provisions of this method, which you cannot possibly
- obtain in legacy engines.
-
diff --git a/addons/web/static/lib/es5-shim/es5-shim.js b/addons/web/static/lib/es5-shim/es5-shim.js
deleted file mode 100644
index 4609ea2f77a..00000000000
--- a/addons/web/static/lib/es5-shim/es5-shim.js
+++ /dev/null
@@ -1,1320 +0,0 @@
-// Copyright 2009-2012 by contributors, MIT License
-// vim: ts=4 sts=4 sw=4 expandtab
-
-// Module systems magic dance
-(function (definition) {
- // RequireJS
- if (typeof define == "function") {
- define(definition);
- // YUI3
- } else if (typeof YUI == "function") {
- YUI.add("es5", definition);
- // CommonJS and <script>
- } else {
- definition();
- }
-})(function () {
-
-/**
- * Brings an environment as close to ECMAScript 5 compliance
- * as is possible with the facilities of erstwhile engines.
- *
- * Annotated ES5: http://es5.github.com/ (specific links below)
- * ES5 Spec: http://www.ecma-international.org/publications/files/ECMA-ST/Ecma-262.pdf
- * Required reading: http://javascriptweblog.wordpress.com/2011/12/05/extending-javascript-natives/
- */
-
-//
-// Function
-// ========
-//
-
-// ES-5 15.3.4.5
-// http://es5.github.com/#x15.3.4.5
-
-function Empty() {}
-
-if (!Function.prototype.bind) {
- Function.prototype.bind = function bind(that) { // .length is 1
- // 1. Let Target be the this value.
- var target = this;
- // 2. If IsCallable(Target) is false, throw a TypeError exception.
- if (typeof target != "function") {
- throw new TypeError("Function.prototype.bind called on incompatible " + target);
- }
- // 3. Let A be a new (possibly empty) internal list of all of the
- // argument values provided after thisArg (arg1, arg2 etc), in order.
- // XXX slicedArgs will stand in for "A" if used
- var args = _Array_slice_.call(arguments, 1); // for normal call
- // 4. Let F be a new native ECMAScript object.
- // 11. Set the [[Prototype]] internal property of F to the standard
- // built-in Function prototype object as specified in 15.3.3.1.
- // 12. Set the [[Call]] internal property of F as described in
- // 15.3.4.5.1.
- // 13. Set the [[Construct]] internal property of F as described in
- // 15.3.4.5.2.
- // 14. Set the [[HasInstance]] internal property of F as described in
- // 15.3.4.5.3.
- var bound = function () {
-
- if (this instanceof bound) {
- // 15.3.4.5.2 [[Construct]]
- // When the [[Construct]] internal method of a function object,
- // F that was created using the bind function is called with a
- // list of arguments ExtraArgs, the following steps are taken:
- // 1. Let target be the value of F's [[TargetFunction]]
- // internal property.
- // 2. If target has no [[Construct]] internal method, a
- // TypeError exception is thrown.
- // 3. Let boundArgs be the value of F's [[BoundArgs]] internal
- // property.
- // 4. Let args be a new list containing the same values as the
- // list boundArgs in the same order followed by the same
- // values as the list ExtraArgs in the same order.
- // 5. Return the result of calling the [[Construct]] internal
- // method of target providing args as the arguments.
-
- var result = target.apply(
- this,
- args.concat(_Array_slice_.call(arguments))
- );
- if (Object(result) === result) {
- return result;
- }
- return this;
-
- } else {
- // 15.3.4.5.1 [[Call]]
- // When the [[Call]] internal method of a function object, F,
- // which was created using the bind function is called with a
- // this value and a list of arguments ExtraArgs, the following
- // steps are taken:
- // 1. Let boundArgs be the value of F's [[BoundArgs]] internal
- // property.
- // 2. Let boundThis be the value of F's [[BoundThis]] internal
- // property.
- // 3. Let target be the value of F's [[TargetFunction]] internal
- // property.
- // 4. Let args be a new list containing the same values as the
- // list boundArgs in the same order followed by the same
- // values as the list ExtraArgs in the same order.
- // 5. Return the result of calling the [[Call]] internal method
- // of target providing boundThis as the this value and
- // providing args as the arguments.
-
- // equiv: target.call(this, ...boundArgs, ...args)
- return target.apply(
- that,
- args.concat(_Array_slice_.call(arguments))
- );
-
- }
-
- };
- if (target.prototype) {
- Empty.prototype = target.prototype;
- bound.prototype = new Empty();
- // Clean up dangling references.
- Empty.prototype = null;
- }
- // XXX bound.length is never writable, so don't even try
- //
- // 15. If the [[Class]] internal property of Target is "Function", then
- // a. Let L be the length property of Target minus the length of A.
- // b. Set the length own property of F to either 0 or L, whichever is
- // larger.
- // 16. Else set the length own property of F to 0.
- // 17. Set the attributes of the length own property of F to the values
- // specified in 15.3.5.1.
-
- // TODO
- // 18. Set the [[Extensible]] internal property of F to true.
-
- // TODO
- // 19. Let thrower be the [[ThrowTypeError]] function Object (13.2.3).
- // 20. Call the [[DefineOwnProperty]] internal method of F with
- // arguments "caller", PropertyDescriptor {[[Get]]: thrower, [[Set]]:
- // thrower, [[Enumerable]]: false, [[Configurable]]: false}, and
- // false.
- // 21. Call the [[DefineOwnProperty]] internal method of F with
- // arguments "arguments", PropertyDescriptor {[[Get]]: thrower,
- // [[Set]]: thrower, [[Enumerable]]: false, [[Configurable]]: false},
- // and false.
-
- // TODO
- // NOTE Function objects created using Function.prototype.bind do not
- // have a prototype property or the [[Code]], [[FormalParameters]], and
- // [[Scope]] internal properties.
- // XXX can't delete prototype in pure-js.
-
- // 22. Return F.
- return bound;
- };
-}
-
-// Shortcut to an often accessed properties, in order to avoid multiple
-// dereference that costs universally.
-// _Please note: Shortcuts are defined after `Function.prototype.bind` as we
-// us it in defining shortcuts.
-var call = Function.prototype.call;
-var prototypeOfArray = Array.prototype;
-var prototypeOfObject = Object.prototype;
-var _Array_slice_ = prototypeOfArray.slice;
-// Having a toString local variable name breaks in Opera so use _toString.
-var _toString = call.bind(prototypeOfObject.toString);
-var owns = call.bind(prototypeOfObject.hasOwnProperty);
-
-// If JS engine supports accessors creating shortcuts.
-var defineGetter;
-var defineSetter;
-var lookupGetter;
-var lookupSetter;
-var supportsAccessors;
-if ((supportsAccessors = owns(prototypeOfObject, "__defineGetter__"))) {
- defineGetter = call.bind(prototypeOfObject.__defineGetter__);
- defineSetter = call.bind(prototypeOfObject.__defineSetter__);
- lookupGetter = call.bind(prototypeOfObject.__lookupGetter__);
- lookupSetter = call.bind(prototypeOfObject.__lookupSetter__);
-}
-
-//
-// Array
-// =====
-//
-
-// ES5 15.4.4.12
-// http://es5.github.com/#x15.4.4.12
-// Default value for second param
-// [bugfix, ielt9, old browsers]
-// IE < 9 bug: [1,2].splice(0).join("") == "" but should be "12"
-if ([1,2].splice(0).length != 2) {
- var array_splice = Array.prototype.splice;
-
- if (function() { // test IE < 9 to splice bug - see issue #138
- function makeArray(l) {
- var a = [];
- while (l--) {
- a.unshift(l)
- }
- return a
- }
-
- var array = []
- , lengthBefore
- ;
-
- array.splice.bind(array, 0, 0).apply(null, makeArray(20));
- array.splice.bind(array, 0, 0).apply(null, makeArray(26));
-
- lengthBefore = array.length; //20
- array.splice(5, 0, "XXX"); // add one element
-
- if (lengthBefore + 1 == array.length) {
- return true;// has right splice implementation without bugs
- }
- // else {
- // IE8 bug
- // }
- }()) {//IE 6/7
- Array.prototype.splice = function(start, deleteCount) {
- if (!arguments.length) {
- return [];
- } else {
- return array_splice.apply(this, [
- start === void 0 ? 0 : start,
- deleteCount === void 0 ? (this.length - start) : deleteCount
- ].concat(_Array_slice_.call(arguments, 2)))
- }
- };
- }
- else {//IE8
- Array.prototype.splice = function(start, deleteCount) {
- var result
- , args = _Array_slice_.call(arguments, 2)
- , addElementsCount = args.length
- ;
-
- if (!arguments.length) {
- return [];
- }
-
- if (start === void 0) { // default
- start = 0;
- }
- if (deleteCount === void 0) { // default
- deleteCount = this.length - start;
- }
-
- if (addElementsCount > 0) {
- if (deleteCount <= 0) {
- if (start == this.length) { // tiny optimisation #1
- this.push.apply(this, args);
- return [];
- }
-
- if (start == 0) { // tiny optimisation #2
- this.unshift.apply(this, args);
- return [];
- }
- }
-
- // Array.prototype.splice implementation
- result = _Array_slice_.call(this, start, start + deleteCount);// delete part
- args.push.apply(args, _Array_slice_.call(this, start + deleteCount, this.length));// right part
- args.unshift.apply(args, _Array_slice_.call(this, 0, start));// left part
-
- // delete all items from this array and replace it to 'left part' + _Array_slice_.call(arguments, 2) + 'right part'
- args.unshift(0, this.length);
-
- array_splice.apply(this, args);
-
- return result;
- }
-
- return array_splice.call(this, start, deleteCount);
- }
-
- }
-}
-
-// ES5 15.4.4.12
-// http://es5.github.com/#x15.4.4.13
-// Return len+argCount.
-// [bugfix, ielt8]
-// IE < 8 bug: [].unshift(0) == undefined but should be "1"
-if ([].unshift(0) != 1) {
- var array_unshift = Array.prototype.unshift;
- Array.prototype.unshift = function() {
- array_unshift.apply(this, arguments);
- return this.length;
- };
-}
-
-// ES5 15.4.3.2
-// http://es5.github.com/#x15.4.3.2
-// https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Array/isArray
-if (!Array.isArray) {
- Array.isArray = function isArray(obj) {
- return _toString(obj) == "[object Array]";
- };
-}
-
-// The IsCallable() check in the Array functions
-// has been replaced with a strict check on the
-// internal class of the object to trap cases where
-// the provided function was actually a regular
-// expression literal, which in V8 and
-// JavaScriptCore is a typeof "function". Only in
-// V8 are regular expression literals permitted as
-// reduce parameters, so it is desirable in the
-// general case for the shim to match the more
-// strict and common behavior of rejecting regular
-// expressions.
-
-// ES5 15.4.4.18
-// http://es5.github.com/#x15.4.4.18
-// https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/array/forEach
-
-// Check failure of by-index access of string characters (IE < 9)
-// and failure of `0 in boxedString` (Rhino)
-var boxedString = Object("a"),
- splitString = boxedString[0] != "a" || !(0 in boxedString);
-
-if (!Array.prototype.forEach) {
- Array.prototype.forEach = function forEach(fun /*, thisp*/) {
- var object = toObject(this),
- self = splitString && _toString(this) == "[object String]" ?
- this.split("") :
- object,
- thisp = arguments[1],
- i = -1,
- length = self.length >>> 0;
-
- // If no callback function or if callback is not a callable function
- if (_toString(fun) != "[object Function]") {
- throw new TypeError(); // TODO message
- }
-
- while (++i < length) {
- if (i in self) {
- // Invoke the callback function with call, passing arguments:
- // context, property value, property key, thisArg object
- // context
- fun.call(thisp, self[i], i, object);
- }
- }
- };
-}
-
-// ES5 15.4.4.19
-// http://es5.github.com/#x15.4.4.19
-// https://developer.mozilla.org/en/Core_JavaScript_1.5_Reference/Objects/Array/map
-if (!Array.prototype.map) {
- Array.prototype.map = function map(fun /*, thisp*/) {
- var object = toObject(this),
- self = splitString && _toString(this) == "[object String]" ?
- this.split("") :
- object,
- length = self.length >>> 0,
- result = Array(length),
- thisp = arguments[1];
-
- // If no callback function or if callback is not a callable function
- if (_toString(fun) != "[object Function]") {
- throw new TypeError(fun + " is not a function");
- }
-
- for (var i = 0; i < length; i++) {
- if (i in self)
- result[i] = fun.call(thisp, self[i], i, object);
- }
- return result;
- };
-}
-
-// ES5 15.4.4.20
-// http://es5.github.com/#x15.4.4.20
-// https://developer.mozilla.org/en/Core_JavaScript_1.5_Reference/Objects/Array/filter
-if (!Array.prototype.filter) {
- Array.prototype.filter = function filter(fun /*, thisp */) {
- var object = toObject(this),
- self = splitString && _toString(this) == "[object String]" ?
- this.split("") :
- object,
- length = self.length >>> 0,
- result = [],
- value,
- thisp = arguments[1];
-
- // If no callback function or if callback is not a callable function
- if (_toString(fun) != "[object Function]") {
- throw new TypeError(fun + " is not a function");
- }
-
- for (var i = 0; i < length; i++) {
- if (i in self) {
- value = self[i];
- if (fun.call(thisp, value, i, object)) {
- result.push(value);
- }
- }
- }
- return result;
- };
-}
-
-// ES5 15.4.4.16
-// http://es5.github.com/#x15.4.4.16
-// https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Array/every
-if (!Array.prototype.every) {
- Array.prototype.every = function every(fun /*, thisp */) {
- var object = toObject(this),
- self = splitString && _toString(this) == "[object String]" ?
- this.split("") :
- object,
- length = self.length >>> 0,
- thisp = arguments[1];
-
- // If no callback function or if callback is not a callable function
- if (_toString(fun) != "[object Function]") {
- throw new TypeError(fun + " is not a function");
- }
-
- for (var i = 0; i < length; i++) {
- if (i in self && !fun.call(thisp, self[i], i, object)) {
- return false;
- }
- }
- return true;
- };
-}
-
-// ES5 15.4.4.17
-// http://es5.github.com/#x15.4.4.17
-// https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Array/some
-if (!Array.prototype.some) {
- Array.prototype.some = function some(fun /*, thisp */) {
- var object = toObject(this),
- self = splitString && _toString(this) == "[object String]" ?
- this.split("") :
- object,
- length = self.length >>> 0,
- thisp = arguments[1];
-
- // If no callback function or if callback is not a callable function
- if (_toString(fun) != "[object Function]") {
- throw new TypeError(fun + " is not a function");
- }
-
- for (var i = 0; i < length; i++) {
- if (i in self && fun.call(thisp, self[i], i, object)) {
- return true;
- }
- }
- return false;
- };
-}
-
-// ES5 15.4.4.21
-// http://es5.github.com/#x15.4.4.21
-// https://developer.mozilla.org/en/Core_JavaScript_1.5_Reference/Objects/Array/reduce
-if (!Array.prototype.reduce) {
- Array.prototype.reduce = function reduce(fun /*, initial*/) {
- var object = toObject(this),
- self = splitString && _toString(this) == "[object String]" ?
- this.split("") :
- object,
- length = self.length >>> 0;
-
- // If no callback function or if callback is not a callable function
- if (_toString(fun) != "[object Function]") {
- throw new TypeError(fun + " is not a function");
- }
-
- // no value to return if no initial value and an empty array
- if (!length && arguments.length == 1) {
- throw new TypeError("reduce of empty array with no initial value");
- }
-
- var i = 0;
- var result;
- if (arguments.length >= 2) {
- result = arguments[1];
- } else {
- do {
- if (i in self) {
- result = self[i++];
- break;
- }
-
- // if array contains no values, no initial value to return
- if (++i >= length) {
- throw new TypeError("reduce of empty array with no initial value");
- }
- } while (true);
- }
-
- for (; i < length; i++) {
- if (i in self) {
- result = fun.call(void 0, result, self[i], i, object);
- }
- }
-
- return result;
- };
-}
-
-// ES5 15.4.4.22
-// http://es5.github.com/#x15.4.4.22
-// https://developer.mozilla.org/en/Core_JavaScript_1.5_Reference/Objects/Array/reduceRight
-if (!Array.prototype.reduceRight) {
- Array.prototype.reduceRight = function reduceRight(fun /*, initial*/) {
- var object = toObject(this),
- self = splitString && _toString(this) == "[object String]" ?
- this.split("") :
- object,
- length = self.length >>> 0;
-
- // If no callback function or if callback is not a callable function
- if (_toString(fun) != "[object Function]") {
- throw new TypeError(fun + " is not a function");
- }
-
- // no value to return if no initial value, empty array
- if (!length && arguments.length == 1) {
- throw new TypeError("reduceRight of empty array with no initial value");
- }
-
- var result, i = length - 1;
- if (arguments.length >= 2) {
- result = arguments[1];
- } else {
- do {
- if (i in self) {
- result = self[i--];
- break;
- }
-
- // if array contains no values, no initial value to return
- if (--i < 0) {
- throw new TypeError("reduceRight of empty array with no initial value");
- }
- } while (true);
- }
-
- if (i < 0) {
- return result;
- }
-
- do {
- if (i in this) {
- result = fun.call(void 0, result, self[i], i, object);
- }
- } while (i--);
-
- return result;
- };
-}
-
-// ES5 15.4.4.14
-// http://es5.github.com/#x15.4.4.14
-// https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Array/indexOf
-if (!Array.prototype.indexOf || ([0, 1].indexOf(1, 2) != -1)) {
- Array.prototype.indexOf = function indexOf(sought /*, fromIndex */ ) {
- var self = splitString && _toString(this) == "[object String]" ?
- this.split("") :
- toObject(this),
- length = self.length >>> 0;
-
- if (!length) {
- return -1;
- }
-
- var i = 0;
- if (arguments.length > 1) {
- i = toInteger(arguments[1]);
- }
-
- // handle negative indices
- i = i >= 0 ? i : Math.max(0, length + i);
- for (; i < length; i++) {
- if (i in self && self[i] === sought) {
- return i;
- }
- }
- return -1;
- };
-}
-
-// ES5 15.4.4.15
-// http://es5.github.com/#x15.4.4.15
-// https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Array/lastIndexOf
-if (!Array.prototype.lastIndexOf || ([0, 1].lastIndexOf(0, -3) != -1)) {
- Array.prototype.lastIndexOf = function lastIndexOf(sought /*, fromIndex */) {
- var self = splitString && _toString(this) == "[object String]" ?
- this.split("") :
- toObject(this),
- length = self.length >>> 0;
-
- if (!length) {
- return -1;
- }
- var i = length - 1;
- if (arguments.length > 1) {
- i = Math.min(i, toInteger(arguments[1]));
- }
- // handle negative indices
- i = i >= 0 ? i : length - Math.abs(i);
- for (; i >= 0; i--) {
- if (i in self && sought === self[i]) {
- return i;
- }
- }
- return -1;
- };
-}
-
-//
-// Object
-// ======
-//
-
-// ES5 15.2.3.14
-// http://es5.github.com/#x15.2.3.14
-if (!Object.keys) {
- // http://whattheheadsaid.com/2010/10/a-safer-object-keys-compatibility-implementation
- var hasDontEnumBug = true,
- dontEnums = [
- "toString",
- "toLocaleString",
- "valueOf",
- "hasOwnProperty",
- "isPrototypeOf",
- "propertyIsEnumerable",
- "constructor"
- ],
- dontEnumsLength = dontEnums.length;
-
- for (var key in {"toString": null}) {
- hasDontEnumBug = false;
- }
-
- Object.keys = function keys(object) {
-
- if (
- (typeof object != "object" && typeof object != "function") ||
- object === null
- ) {
- throw new TypeError("Object.keys called on a non-object");
- }
-
- var keys = [];
- for (var name in object) {
- if (owns(object, name)) {
- keys.push(name);
- }
- }
-
- if (hasDontEnumBug) {
- for (var i = 0, ii = dontEnumsLength; i < ii; i++) {
- var dontEnum = dontEnums[i];
- if (owns(object, dontEnum)) {
- keys.push(dontEnum);
- }
- }
- }
- return keys;
- };
-
-}
-
-//
-// Date
-// ====
-//
-
-// ES5 15.9.5.43
-// http://es5.github.com/#x15.9.5.43
-// This function returns a String value represent the instance in time
-// represented by this Date object. The format of the String is the Date Time
-// string format defined in 15.9.1.15. All fields are present in the String.
-// The time zone is always UTC, denoted by the suffix Z. If the time value of
-// this object is not a finite Number a RangeError exception is thrown.
-var negativeDate = -62198755200000,
- negativeYearString = "-000001";
-if (
- !Date.prototype.toISOString ||
- (new Date(negativeDate).toISOString().indexOf(negativeYearString) === -1)
-) {
- Date.prototype.toISOString = function toISOString() {
- var result, length, value, year, month;
- if (!isFinite(this)) {
- throw new RangeError("Date.prototype.toISOString called on non-finite value.");
- }
-
- year = this.getUTCFullYear();
-
- month = this.getUTCMonth();
- // see https://github.com/kriskowal/es5-shim/issues/111
- year += Math.floor(month / 12);
- month = (month % 12 + 12) % 12;
-
- // the date time string format is specified in 15.9.1.15.
- result = [month + 1, this.getUTCDate(),
- this.getUTCHours(), this.getUTCMinutes(), this.getUTCSeconds()];
- year = (
- (year < 0 ? "-" : (year > 9999 ? "+" : "")) +
- ("00000" + Math.abs(year))
- .slice(0 <= year && year <= 9999 ? -4 : -6)
- );
-
- length = result.length;
- while (length--) {
- value = result[length];
- // pad months, days, hours, minutes, and seconds to have two
- // digits.
- if (value < 10) {
- result[length] = "0" + value;
- }
- }
- // pad milliseconds to have three digits.
- return (
- year + "-" + result.slice(0, 2).join("-") +
- "T" + result.slice(2).join(":") + "." +
- ("000" + this.getUTCMilliseconds()).slice(-3) + "Z"
- );
- };
-}
-
-
-// ES5 15.9.5.44
-// http://es5.github.com/#x15.9.5.44
-// This function provides a String representation of a Date object for use by
-// JSON.stringify (15.12.3).
-var dateToJSONIsSupported = false;
-try {
- dateToJSONIsSupported = (
- Date.prototype.toJSON &&
- new Date(NaN).toJSON() === null &&
- new Date(negativeDate).toJSON().indexOf(negativeYearString) !== -1 &&
- Date.prototype.toJSON.call({ // generic
- toISOString: function () {
- return true;
- }
- })
- );
-} catch (e) {
-}
-if (!dateToJSONIsSupported) {
- Date.prototype.toJSON = function toJSON(key) {
- // When the toJSON method is called with argument key, the following
- // steps are taken:
-
- // 1. Let O be the result of calling ToObject, giving it the this
- // value as its argument.
- // 2. Let tv be toPrimitive(O, hint Number).
- var o = Object(this),
- tv = toPrimitive(o),
- toISO;
- // 3. If tv is a Number and is not finite, return null.
- if (typeof tv === "number" && !isFinite(tv)) {
- return null;
- }
- // 4. Let toISO be the result of calling the [[Get]] internal method of
- // O with argument "toISOString".
- toISO = o.toISOString;
- // 5. If IsCallable(toISO) is false, throw a TypeError exception.
- if (typeof toISO != "function") {
- throw new TypeError("toISOString property is not callable");
- }
- // 6. Return the result of calling the [[Call]] internal method of
- // toISO with O as the this value and an empty argument list.
- return toISO.call(o);
-
- // NOTE 1 The argument is ignored.
-
- // NOTE 2 The toJSON function is intentionally generic; it does not
- // require that its this value be a Date object. Therefore, it can be
- // transferred to other kinds of objects for use as a method. However,
- // it does require that any such object have a toISOString method. An
- // object is free to use the argument key to filter its
- // stringification.
- };
-}
-
-// ES5 15.9.4.2
-// http://es5.github.com/#x15.9.4.2
-// based on work shared by Daniel Friesen (dantman)
-// http://gist.github.com/303249
-if (!Date.parse || "Date.parse is buggy") {
- // XXX global assignment won't work in embeddings that use
- // an alternate object for the context.
- Date = (function(NativeDate) {
-
- // Date.length === 7
- function Date(Y, M, D, h, m, s, ms) {
- var length = arguments.length;
- if (this instanceof NativeDate) {
- var date = length == 1 && String(Y) === Y ? // isString(Y)
- // We explicitly pass it through parse:
- new NativeDate(Date.parse(Y)) :
- // We have to manually make calls depending on argument
- // length here
- length >= 7 ? new NativeDate(Y, M, D, h, m, s, ms) :
- length >= 6 ? new NativeDate(Y, M, D, h, m, s) :
- length >= 5 ? new NativeDate(Y, M, D, h, m) :
- length >= 4 ? new NativeDate(Y, M, D, h) :
- length >= 3 ? new NativeDate(Y, M, D) :
- length >= 2 ? new NativeDate(Y, M) :
- length >= 1 ? new NativeDate(Y) :
- new NativeDate();
- // Prevent mixups with unfixed Date object
- date.constructor = Date;
- return date;
- }
- return NativeDate.apply(this, arguments);
- };
-
- // 15.9.1.15 Date Time String Format.
- var isoDateExpression = new RegExp("^" +
- "(\\d{4}|[\+\-]\\d{6})" + // four-digit year capture or sign +
- // 6-digit extended year
- "(?:-(\\d{2})" + // optional month capture
- "(?:-(\\d{2})" + // optional day capture
- "(?:" + // capture hours:minutes:seconds.milliseconds
- "T(\\d{2})" + // hours capture
- ":(\\d{2})" + // minutes capture
- "(?:" + // optional :seconds.milliseconds
- ":(\\d{2})" + // seconds capture
- "(?:(\\.\\d{1,}))?" + // milliseconds capture
- ")?" +
- "(" + // capture UTC offset component
- "Z|" + // UTC capture
- "(?:" + // offset specifier +/-hours:minutes
- "([-+])" + // sign capture
- "(\\d{2})" + // hours offset capture
- ":(\\d{2})" + // minutes offset capture
- ")" +
- ")?)?)?)?" +
- "$");
-
- var months = [
- 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365
- ];
-
- function dayFromMonth(year, month) {
- var t = month > 1 ? 1 : 0;
- return (
- months[month] +
- Math.floor((year - 1969 + t) / 4) -
- Math.floor((year - 1901 + t) / 100) +
- Math.floor((year - 1601 + t) / 400) +
- 365 * (year - 1970)
- );
- }
-
- function toUTC(t) {
- return Number(new NativeDate(1970, 0, 1, 0, 0, 0, t));
- }
-
- // Copy any custom methods a 3rd party library may have added
- for (var key in NativeDate) {
- Date[key] = NativeDate[key];
- }
-
- // Copy "native" methods explicitly; they may be non-enumerable
- Date.now = NativeDate.now;
- Date.UTC = NativeDate.UTC;
- Date.prototype = NativeDate.prototype;
- Date.prototype.constructor = Date;
-
- // Upgrade Date.parse to handle simplified ISO 8601 strings
- Date.parse = function parse(string) {
- var match = isoDateExpression.exec(string);
- if (match) {
- // parse months, days, hours, minutes, seconds, and milliseconds
- // provide default values if necessary
- // parse the UTC offset component
- var year = Number(match[1]),
- month = Number(match[2] || 1) - 1,
- day = Number(match[3] || 1) - 1,
- hour = Number(match[4] || 0),
- minute = Number(match[5] || 0),
- second = Number(match[6] || 0),
- millisecond = Math.floor(Number(match[7] || 0) * 1000),
- // When time zone is missed, local offset should be used
- // (ES 5.1 bug)
- // see https://bugs.ecmascript.org/show_bug.cgi?id=112
- isLocalTime = Boolean(match[4] && !match[8]),
- signOffset = match[9] === "-" ? 1 : -1,
- hourOffset = Number(match[10] || 0),
- minuteOffset = Number(match[11] || 0),
- result;
- if (
- hour < (
- minute > 0 || second > 0 || millisecond > 0 ?
- 24 : 25
- ) &&
- minute < 60 && second < 60 && millisecond < 1000 &&
- month > -1 && month < 12 && hourOffset < 24 &&
- minuteOffset < 60 && // detect invalid offsets
- day > -1 &&
- day < (
- dayFromMonth(year, month + 1) -
- dayFromMonth(year, month)
- )
- ) {
- result = (
- (dayFromMonth(year, month) + day) * 24 +
- hour +
- hourOffset * signOffset
- ) * 60;
- result = (
- (result + minute + minuteOffset * signOffset) * 60 +
- second
- ) * 1000 + millisecond;
- if (isLocalTime) {
- result = toUTC(result);
- }
- if (-8.64e15 <= result && result <= 8.64e15) {
- return result;
- }
- }
- return NaN;
- }
- return NativeDate.parse.apply(this, arguments);
- };
-
- return Date;
- })(Date);
-}
-
-// ES5 15.9.4.4
-// http://es5.github.com/#x15.9.4.4
-if (!Date.now) {
- Date.now = function now() {
- return new Date().getTime();
- };
-}
-
-
-//
-// Number
-// ======
-//
-
-// ES5.1 15.7.4.5
-// http://es5.github.com/#x15.7.4.5
-if (!Number.prototype.toFixed || (0.00008).toFixed(3) !== '0.000' || (0.9).toFixed(0) === '0' || (1.255).toFixed(2) !== '1.25' || (1000000000000000128).toFixed(0) !== "1000000000000000128") {
- // Hide these variables and functions
- (function () {
- var base, size, data, i;
-
- base = 1e7;
- size = 6;
- data = [0, 0, 0, 0, 0, 0];
-
- function multiply(n, c) {
- var i = -1;
- while (++i < size) {
- c += n * data[i];
- data[i] = c % base;
- c = Math.floor(c / base);
- }
- }
-
- function divide(n) {
- var i = size, c = 0;
- while (--i >= 0) {
- c += data[i];
- data[i] = Math.floor(c / n);
- c = (c % n) * base;
- }
- }
-
- function toString() {
- var i = size;
- var s = '';
- while (--i >= 0) {
- if (s !== '' || i === 0 || data[i] !== 0) {
- var t = String(data[i]);
- if (s === '') {
- s = t;
- } else {
- s += '0000000'.slice(0, 7 - t.length) + t;
- }
- }
- }
- return s;
- }
-
- function pow(x, n, acc) {
- return (n === 0 ? acc : (n % 2 === 1 ? pow(x, n - 1, acc * x) : pow(x * x, n / 2, acc)));
- }
-
- function log(x) {
- var n = 0;
- while (x >= 4096) {
- n += 12;
- x /= 4096;
- }
- while (x >= 2) {
- n += 1;
- x /= 2;
- }
- return n;
- }
-
- Number.prototype.toFixed = function (fractionDigits) {
- var f, x, s, m, e, z, j, k;
-
- // Test for NaN and round fractionDigits down
- f = Number(fractionDigits);
- f = f !== f ? 0 : Math.floor(f);
-
- if (f < 0 || f > 20) {
- throw new RangeError("Number.toFixed called with invalid number of decimals");
- }
-
- x = Number(this);
-
- // Test for NaN
- if (x !== x) {
- return "NaN";
- }
-
- // If it is too big or small, return the string value of the number
- if (x <= -1e21 || x >= 1e21) {
- return String(x);
- }
-
- s = "";
-
- if (x < 0) {
- s = "-";
- x = -x;
- }
-
- m = "0";
-
- if (x > 1e-21) {
- // 1e-21 < x < 1e21
- // -70 < log2(x) < 70
- e = log(x * pow(2, 69, 1)) - 69;
- z = (e < 0 ? x * pow(2, -e, 1) : x / pow(2, e, 1));
- z *= 0x10000000000000; // Math.pow(2, 52);
- e = 52 - e;
-
- // -18 < e < 122
- // x = z / 2 ^ e
- if (e > 0) {
- multiply(0, z);
- j = f;
-
- while (j >= 7) {
- multiply(1e7, 0);
- j -= 7;
- }
-
- multiply(pow(10, j, 1), 0);
- j = e - 1;
-
- while (j >= 23) {
- divide(1 << 23);
- j -= 23;
- }
-
- divide(1 << j);
- multiply(1, 1);
- divide(2);
- m = toString();
- } else {
- multiply(0, z);
- multiply(1 << (-e), 0);
- m = toString() + '0.00000000000000000000'.slice(2, 2 + f);
- }
- }
-
- if (f > 0) {
- k = m.length;
-
- if (k <= f) {
- m = s + '0.0000000000000000000'.slice(0, f - k + 2) + m;
- } else {
- m = s + m.slice(0, k - f) + '.' + m.slice(k - f);
- }
- } else {
- m = s + m;
- }
-
- return m;
- }
- }());
-}
-
-
-//
-// String
-// ======
-//
-
-
-// ES5 15.5.4.14
-// http://es5.github.com/#x15.5.4.14
-
-// [bugfix, IE lt 9, firefox 4, Konqueror, Opera, obscure browsers]
-// Many browsers do not split properly with regular expressions or they
-// do not perform the split correctly under obscure conditions.
-// See http://blog.stevenlevithan.com/archives/cross-browser-split
-// I've tested in many browsers and this seems to cover the deviant ones:
-// 'ab'.split(/(?:ab)*/) should be ["", ""], not [""]
-// '.'.split(/(.?)(.?)/) should be ["", ".", "", ""], not ["", ""]
-// 'tesst'.split(/(s)*/) should be ["t", undefined, "e", "s", "t"], not
-// [undefined, "t", undefined, "e", ...]
-// ''.split(/.?/) should be [], not [""]
-// '.'.split(/()()/) should be ["."], not ["", "", "."]
-
-var string_split = String.prototype.split;
-if (
- 'ab'.split(/(?:ab)*/).length !== 2 ||
- '.'.split(/(.?)(.?)/).length !== 4 ||
- 'tesst'.split(/(s)*/)[1] === "t" ||
- ''.split(/.?/).length ||
- '.'.split(/()()/).length > 1
-) {
- (function () {
- var compliantExecNpcg = /()??/.exec("")[1] === void 0; // NPCG: nonparticipating capturing group
-
- String.prototype.split = function (separator, limit) {
- var string = this;
- if (separator === void 0 && limit === 0)
- return [];
-
- // If `separator` is not a regex, use native split
- if (Object.prototype.toString.call(separator) !== "[object RegExp]") {
- return string_split.apply(this, arguments);
- }
-
- var output = [],
- flags = (separator.ignoreCase ? "i" : "") +
- (separator.multiline ? "m" : "") +
- (separator.extended ? "x" : "") + // Proposed for ES6
- (separator.sticky ? "y" : ""), // Firefox 3+
- lastLastIndex = 0,
- // Make `global` and avoid `lastIndex` issues by working with a copy
- separator = new RegExp(separator.source, flags + "g"),
- separator2, match, lastIndex, lastLength;
- string += ""; // Type-convert
- if (!compliantExecNpcg) {
- // Doesn't need flags gy, but they don't hurt
- separator2 = new RegExp("^" + separator.source + "$(?!\\s)", flags);
- }
- /* Values for `limit`, per the spec:
- * If undefined: 4294967295 // Math.pow(2, 32) - 1
- * If 0, Infinity, or NaN: 0
- * If positive number: limit = Math.floor(limit); if (limit > 4294967295) limit -= 4294967296;
- * If negative number: 4294967296 - Math.floor(Math.abs(limit))
- * If other: Type-convert, then use the above rules
- */
- limit = limit === void 0 ?
- -1 >>> 0 : // Math.pow(2, 32) - 1
- limit >>> 0; // ToUint32(limit)
- while (match = separator.exec(string)) {
- // `separator.lastIndex` is not reliable cross-browser
- lastIndex = match.index + match[0].length;
- if (lastIndex > lastLastIndex) {
- output.push(string.slice(lastLastIndex, match.index));
- // Fix browsers whose `exec` methods don't consistently return `undefined` for
- // nonparticipating capturing groups
- if (!compliantExecNpcg && match.length > 1) {
- match[0].replace(separator2, function () {
- for (var i = 1; i < arguments.length - 2; i++) {
- if (arguments[i] === void 0) {
- match[i] = void 0;
- }
- }
- });
- }
- if (match.length > 1 && match.index < string.length) {
- Array.prototype.push.apply(output, match.slice(1));
- }
- lastLength = match[0].length;
- lastLastIndex = lastIndex;
- if (output.length >= limit) {
- break;
- }
- }
- if (separator.lastIndex === match.index) {
- separator.lastIndex++; // Avoid an infinite loop
- }
- }
- if (lastLastIndex === string.length) {
- if (lastLength || !separator.test("")) {
- output.push("");
- }
- } else {
- output.push(string.slice(lastLastIndex));
- }
- return output.length > limit ? output.slice(0, limit) : output;
- };
- }());
-
-// [bugfix, chrome]
-// If separator is undefined, then the result array contains just one String,
-// which is the this value (converted to a String). If limit is not undefined,
-// then the output array is truncated so that it contains no more than limit
-// elements.
-// "0".split(undefined, 0) -> []
-} else if ("0".split(void 0, 0).length) {
- String.prototype.split = function(separator, limit) {
- if (separator === void 0 && limit === 0) return [];
- return string_split.apply(this, arguments);
- }
-}
-
-
-// ECMA-262, 3rd B.2.3
-// Note an ECMAScript standart, although ECMAScript 3rd Edition has a
-// non-normative section suggesting uniform semantics and it should be
-// normalized across all browsers
-// [bugfix, IE lt 9] IE < 9 substr() with negative value not working in IE
-if ("".substr && "0b".substr(-1) !== "b") {
- var string_substr = String.prototype.substr;
- /**
- * Get the substring of a string
- * @param {integer} start where to start the substring
- * @param {integer} length how many characters to return
- * @return {string}
- */
- String.prototype.substr = function(start, length) {
- return string_substr.call(
- this,
- start < 0 ? ((start = this.length + start) < 0 ? 0 : start) : start,
- length
- );
- }
-}
-
-// ES5 15.5.4.20
-// http://es5.github.com/#x15.5.4.20
-var ws = "\x09\x0A\x0B\x0C\x0D\x20\xA0\u1680\u180E\u2000\u2001\u2002\u2003" +
- "\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000\u2028" +
- "\u2029\uFEFF";
-if (!String.prototype.trim || ws.trim()) {
- // http://blog.stevenlevithan.com/archives/faster-trim-javascript
- // http://perfectionkills.com/whitespace-deviations/
- ws = "[" + ws + "]";
- var trimBeginRegexp = new RegExp("^" + ws + ws + "*"),
- trimEndRegexp = new RegExp(ws + ws + "*$");
- String.prototype.trim = function trim() {
- if (this === void 0 || this === null) {
- throw new TypeError("can't convert "+this+" to object");
- }
- return String(this)
- .replace(trimBeginRegexp, "")
- .replace(trimEndRegexp, "");
- };
-}
-
-//
-// Util
-// ======
-//
-
-// ES5 9.4
-// http://es5.github.com/#x9.4
-// http://jsperf.com/to-integer
-
-function toInteger(n) {
- n = +n;
- if (n !== n) { // isNaN
- n = 0;
- } else if (n !== 0 && n !== (1/0) && n !== -(1/0)) {
- n = (n > 0 || -1) * Math.floor(Math.abs(n));
- }
- return n;
-}
-
-function isPrimitive(input) {
- var type = typeof input;
- return (
- input === null ||
- type === "undefined" ||
- type === "boolean" ||
- type === "number" ||
- type === "string"
- );
-}
-
-function toPrimitive(input) {
- var val, valueOf, toString;
- if (isPrimitive(input)) {
- return input;
- }
- valueOf = input.valueOf;
- if (typeof valueOf === "function") {
- val = valueOf.call(input);
- if (isPrimitive(val)) {
- return val;
- }
- }
- toString = input.toString;
- if (typeof toString === "function") {
- val = toString.call(input);
- if (isPrimitive(val)) {
- return val;
- }
- }
- throw new TypeError();
-}
-
-// ES5 9.9
-// http://es5.github.com/#x9.9
-var toObject = function (o) {
- if (o == null) { // this matches both null and undefined
- throw new TypeError("can't convert "+o+" to object");
- }
- return Object(o);
-};
-
-});
diff --git a/addons/web/static/lib/es5-shim/es5-shim.map b/addons/web/static/lib/es5-shim/es5-shim.map
deleted file mode 100644
index 3f56fe6e289..00000000000
--- a/addons/web/static/lib/es5-shim/es5-shim.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"sources":["es5-shim.js"],"names":["definition","define","YUI","add","Empty","Function","prototype","bind","that","target","this","TypeError","args","_Array_slice_","call","arguments","bound","result","apply","concat","Object","prototypeOfArray","Array","prototypeOfObject","slice","_toString","toString","owns","hasOwnProperty","defineGetter","defineSetter","lookupGetter","lookupSetter","supportsAccessors","__defineGetter__","__defineSetter__","__lookupGetter__","__lookupSetter__","splice","length","array_splice","makeArray","l","a","unshift","array","lengthBefore","start","deleteCount","addElementsCount","push","array_unshift","isArray","obj","boxedString","splitString","forEach","fun","object","toObject","self","split","thisp","i","map","filter","value","every","some","reduce","reduceRight","indexOf","sought","toInteger","Math","max","lastIndexOf","min","abs","keys","hasDontEnumBug","dontEnums","dontEnumsLength","key","name","ii","dontEnum","negativeDate","negativeYearString","Date","toISOString","year","month","isFinite","RangeError","getUTCFullYear","getUTCMonth","floor","getUTCDate","getUTCHours","getUTCMinutes","getUTCSeconds","join","getUTCMilliseconds","dateToJSONIsSupported","toJSON","NaN","e","o","tv","toPrimitive","toISO","parse","NativeDate","Y","M","D","h","m","s","ms","date","String","constructor","isoDateExpression","RegExp","months","dayFromMonth","t","now","UTC","string","match","exec","Number","day","hour","minute","second","millisecond","offset","signOffset","hourOffset","minuteOffset","getTime","toFixed","base","size","data","multiply","n","c","divide","pow","x","acc","log","fractionDigits","f","z","j","k","string_split","compliantExecNpcg","separator","limit","output","flags","ignoreCase","multiline","extended","sticky","lastLastIndex","source","separator2","lastIndex","lastLength","index","replace","test","substr","string_substr","ws","trim","trimBeginRegexp","trimEndRegexp","isPrimitive","input","type","val","valueOf"],"mappings":"CAIA,SAAWA,YAEP,SAAWC,SAAU,WAAY,CAC7BA,OAAOD,gBAEJ,UAAWE,MAAO,WAAY,CACjCA,IAAIC,IAAI,MAAOH,gBAEZ,CACHA,gBAEL,WAmBH,QAASI,UAET,IAAKC,SAASC,UAAUC,KAAM,CAC1BF,SAASC,UAAUC,KAAO,QAASA,MAAKC,MAEpC,GAAIC,QAASC,IAEb,UAAWD,SAAU,WAAY,CAC7B,KAAM,IAAIE,WAAU,kDAAoDF,QAK5E,GAAIG,MAAOC,cAAcC,KAAKC,UAAW,EAUzC,IAAIC,OAAQ,WAER,GAAIN,eAAgBM,OAAO,CAiBvB,GAAIC,QAASR,OAAOS,MAChBR,KACAE,KAAKO,OAAON,cAAcC,KAAKC,YAEnC,IAAIK,OAAOH,UAAYA,OAAQ,CAC3B,MAAOA,QAEX,MAAOP,UAEJ,CAoBH,MAAOD,QAAOS,MACVV,KACAI,KAAKO,OAAON,cAAcC,KAAKC,cAM3C,IAAGN,OAAOH,UAAW,CACjBF,MAAME,UAAYG,OAAOH,SACzBU,OAAMV,UAAY,GAAIF,MAEtBA,OAAME,UAAY,KAiCtB,MAAOU,QAQf,GAAIF,MAAOT,SAASC,UAAUQ,IAC9B,IAAIO,kBAAmBC,MAAMhB,SAC7B,IAAIiB,mBAAoBH,OAAOd,SAC/B,IAAIO,eAAgBQ,iBAAiBG,KAErC,IAAIC,WAAYX,KAAKP,KAAKgB,kBAAkBG,SAC5C,IAAIC,MAAOb,KAAKP,KAAKgB,kBAAkBK,eAGvC,IAAIC,aACJ,IAAIC,aACJ,IAAIC,aACJ,IAAIC,aACJ,IAAIC,kBACJ,IAAKA,kBAAoBN,KAAKJ,kBAAmB,oBAAsB,CACnEM,aAAef,KAAKP,KAAKgB,kBAAkBW,iBAC3CJ,cAAehB,KAAKP,KAAKgB,kBAAkBY,iBAC3CJ,cAAejB,KAAKP,KAAKgB,kBAAkBa,iBAC3CJ,cAAelB,KAAKP,KAAKgB,kBAAkBc,kBAa/C,IAAK,EAAE,GAAGC,OAAO,GAAGC,QAAU,EAAG,CAC7B,GAAIC,cAAelB,MAAMhB,UAAUgC,MAEnC,IAAG,WACC,QAASG,WAAUC,GACf,GAAIC,KACJ,OAAOD,IAAK,CACRC,EAAEC,QAAQF,GAEd,MAAOC,GAGX,GAAIE,UACEC,YAGND,OAAMP,OAAO/B,KAAKsC,MAAO,EAAG,GAAG3B,MAAM,KAAMuB,UAAU,IACrDI,OAAMP,OAAO/B,KAAKsC,MAAO,EAAG,GAAG3B,MAAM,KAAMuB,UAAU,IAErDK,cAAeD,MAAMN,MACrBM,OAAMP,OAAO,EAAG,EAAG,MAEnB,IAAGQ,aAAe,GAAKD,MAAMN,OAAQ,CACjC,MAAO,UAKV,CACDjB,MAAMhB,UAAUgC,OAAS,SAASS,MAAOC,aACrC,IAAKjC,UAAUwB,OAAQ,CACnB,aACG,CACH,MAAOC,cAAatB,MAAMR,MACtBqC,YAAe,GAAI,EAAIA,MACvBC,kBAAqB,GAAKtC,KAAK6B,OAASQ,MAASC,aACnD7B,OAAON,cAAcC,KAAKC,UAAW,WAI9C,CACDO,MAAMhB,UAAUgC,OAAS,SAASS,MAAOC,aACrC,GAAI/B,QACEL,KAAOC,cAAcC,KAAKC,UAAW,GACrCkC,iBAAmBrC,KAAK2B,MAG9B,KAAIxB,UAAUwB,OAAQ,CAClB,SAGJ,GAAGQ,YAAe,GAAG,CACjBA,MAAQ,EAEZ,GAAGC,kBAAqB,GAAG,CACvBA,YAActC,KAAK6B,OAASQ,MAGhC,GAAGE,iBAAmB,EAAG,CACrB,GAAGD,aAAe,EAAG,CACjB,GAAGD,OAASrC,KAAK6B,OAAQ,CACrB7B,KAAKwC,KAAKhC,MAAMR,KAAME,KACtB,UAGJ,GAAGmC,OAAS,EAAG,CACXrC,KAAKkC,QAAQ1B,MAAMR,KAAME,KACzB,WAKRK,OAASJ,cAAcC,KAAKJ,KAAMqC,MAAOA,MAAQC,YACjDpC,MAAKsC,KAAKhC,MAAMN,KAAMC,cAAcC,KAAKJ,KAAMqC,MAAQC,YAAatC,KAAK6B,QACzE3B,MAAKgC,QAAQ1B,MAAMN,KAAMC,cAAcC,KAAKJ,KAAM,EAAGqC,OAGrDnC,MAAKgC,QAAQ,EAAGlC,KAAK6B,OAErBC,cAAatB,MAAMR,KAAME,KAEzB,OAAOK,QAGX,MAAOuB,cAAa1B,KAAKJ,KAAMqC,MAAOC,eAWlD,MAAOJ,QAAQ,IAAM,EAAG,CACpB,GAAIO,eAAgB7B,MAAMhB,UAAUsC,OACpCtB,OAAMhB,UAAUsC,QAAU,WACtBO,cAAcjC,MAAMR,KAAMK,UAC1B,OAAOL,MAAK6B,QAOpB,IAAKjB,MAAM8B,QAAS,CAChB9B,MAAM8B,QAAU,QAASA,SAAQC,KAC7B,MAAO5B,WAAU4B,MAAQ,kBAsBjC,GAAIC,aAAclC,OAAO,KACrBmC,YAAcD,YAAY,IAAM,OAAS,IAAKA,aAElD,KAAKhC,MAAMhB,UAAUkD,QAAS,CAC1BlC,MAAMhB,UAAUkD,QAAU,QAASA,SAAQC,KACvC,GAAIC,QAASC,SAASjD,MAClBkD,KAAOL,aAAe9B,UAAUf,OAAS,kBACrCA,KAAKmD,MAAM,IACXH,OACJI,MAAQ/C,UAAU,GAClBgD,GAAK,EACLxB,OAASqB,KAAKrB,SAAW,CAG7B,IAAId,UAAUgC,MAAQ,oBAAqB,CACvC,KAAM,IAAI9C,WAGd,QAASoD,EAAIxB,OAAQ,CACjB,GAAIwB,IAAKH,MAAM,CAIXH,IAAI3C,KAAKgD,MAAOF,KAAKG,GAAIA,EAAGL,WAS5C,IAAKpC,MAAMhB,UAAU0D,IAAK,CACtB1C,MAAMhB,UAAU0D,IAAM,QAASA,KAAIP,KAC/B,GAAIC,QAASC,SAASjD,MAClBkD,KAAOL,aAAe9B,UAAUf,OAAS,kBACrCA,KAAKmD,MAAM,IACXH,OACJnB,OAASqB,KAAKrB,SAAW,EACzBtB,OAASK,MAAMiB,QACfuB,MAAQ/C,UAAU,EAGtB,IAAIU,UAAUgC,MAAQ,oBAAqB,CACvC,KAAM,IAAI9C,WAAU8C,IAAM,sBAG9B,IAAK,GAAIM,GAAI,EAAGA,EAAIxB,OAAQwB,IAAK,CAC7B,GAAIA,IAAKH,MACL3C,OAAO8C,GAAKN,IAAI3C,KAAKgD,MAAOF,KAAKG,GAAIA,EAAGL,QAEhD,MAAOzC,SAOf,IAAKK,MAAMhB,UAAU2D,OAAQ,CACzB3C,MAAMhB,UAAU2D,OAAS,QAASA,QAAOR,KACrC,GAAIC,QAASC,SAASjD,MAClBkD,KAAOL,aAAe9B,UAAUf,OAAS,kBACrCA,KAAKmD,MAAM,IACPH,OACRnB,OAASqB,KAAKrB,SAAW,EACzBtB,UACAiD,MACAJ,MAAQ/C,UAAU,EAGtB,IAAIU,UAAUgC,MAAQ,oBAAqB,CACvC,KAAM,IAAI9C,WAAU8C,IAAM,sBAG9B,IAAK,GAAIM,GAAI,EAAGA,EAAIxB,OAAQwB,IAAK,CAC7B,GAAIA,IAAKH,MAAM,CACXM,MAAQN,KAAKG,EACb,IAAIN,IAAI3C,KAAKgD,MAAOI,MAAOH,EAAGL,QAAS,CACnCzC,OAAOiC,KAAKgB,SAIxB,MAAOjD,SAOf,IAAKK,MAAMhB,UAAU6D,MAAO,CACxB7C,MAAMhB,UAAU6D,MAAQ,QAASA,OAAMV,KACnC,GAAIC,QAASC,SAASjD,MAClBkD,KAAOL,aAAe9B,UAAUf,OAAS,kBACrCA,KAAKmD,MAAM,IACXH,OACJnB,OAASqB,KAAKrB,SAAW,EACzBuB,MAAQ/C,UAAU,EAGtB,IAAIU,UAAUgC,MAAQ,oBAAqB,CACvC,KAAM,IAAI9C,WAAU8C,IAAM,sBAG9B,IAAK,GAAIM,GAAI,EAAGA,EAAIxB,OAAQwB,IAAK,CAC7B,GAAIA,IAAKH,QAASH,IAAI3C,KAAKgD,MAAOF,KAAKG,GAAIA,EAAGL,QAAS,CACnD,MAAO,QAGf,MAAO,OAOf,IAAKpC,MAAMhB,UAAU8D,KAAM,CACvB9C,MAAMhB,UAAU8D,KAAO,QAASA,MAAKX,KACjC,GAAIC,QAASC,SAASjD,MAClBkD,KAAOL,aAAe9B,UAAUf,OAAS,kBACrCA,KAAKmD,MAAM,IACXH,OACJnB,OAASqB,KAAKrB,SAAW,EACzBuB,MAAQ/C,UAAU,EAGtB,IAAIU,UAAUgC,MAAQ,oBAAqB,CACvC,KAAM,IAAI9C,WAAU8C,IAAM,sBAG9B,IAAK,GAAIM,GAAI,EAAGA,EAAIxB,OAAQwB,IAAK,CAC7B,GAAIA,IAAKH,OAAQH,IAAI3C,KAAKgD,MAAOF,KAAKG,GAAIA,EAAGL,QAAS,CAClD,MAAO,OAGf,MAAO,QAOf,IAAKpC,MAAMhB,UAAU+D,OAAQ,CACzB/C,MAAMhB,UAAU+D,OAAS,QAASA,QAAOZ,KACrC,GAAIC,QAASC,SAASjD,MAClBkD,KAAOL,aAAe9B,UAAUf,OAAS,kBACrCA,KAAKmD,MAAM,IACXH,OACJnB,OAASqB,KAAKrB,SAAW,CAG7B,IAAId,UAAUgC,MAAQ,oBAAqB,CACvC,KAAM,IAAI9C,WAAU8C,IAAM,sBAI9B,IAAKlB,QAAUxB,UAAUwB,QAAU,EAAG,CAClC,KAAM,IAAI5B,WAAU,+CAGxB,GAAIoD,GAAI,CACR,IAAI9C,OACJ,IAAIF,UAAUwB,QAAU,EAAG,CACvBtB,OAASF,UAAU,OAChB,CACH,EAAG,CACC,GAAIgD,IAAKH,MAAM,CACX3C,OAAS2C,KAAKG,IACd,OAIJ,KAAMA,GAAKxB,OAAQ,CACf,KAAM,IAAI5B,WAAU,sDAEnB,MAGb,KAAOoD,EAAIxB,OAAQwB,IAAK,CACpB,GAAIA,IAAKH,MAAM,CACX3C,OAASwC,IAAI3C,SAAU,GAAGG,OAAQ2C,KAAKG,GAAIA,EAAGL,SAItD,MAAOzC,SAOf,IAAKK,MAAMhB,UAAUgE,YAAa,CAC9BhD,MAAMhB,UAAUgE,YAAc,QAASA,aAAYb,KAC/C,GAAIC,QAASC,SAASjD,MAClBkD,KAAOL,aAAe9B,UAAUf,OAAS,kBACrCA,KAAKmD,MAAM,IACXH,OACJnB,OAASqB,KAAKrB,SAAW,CAG7B,IAAId,UAAUgC,MAAQ,oBAAqB,CACvC,KAAM,IAAI9C,WAAU8C,IAAM,sBAI9B,IAAKlB,QAAUxB,UAAUwB,QAAU,EAAG,CAClC,KAAM,IAAI5B,WAAU,oDAGxB,GAAIM,QAAQ8C,EAAIxB,OAAS,CACzB,IAAIxB,UAAUwB,QAAU,EAAG,CACvBtB,OAASF,UAAU,OAChB,CACH,EAAG,CACC,GAAIgD,IAAKH,MAAM,CACX3C,OAAS2C,KAAKG,IACd,OAIJ,KAAMA,EAAI,EAAG,CACT,KAAM,IAAIpD,WAAU,2DAEnB,MAGb,GAAIoD,EAAI,EAAG,CACP,MAAO9C,QAGX,EAAG,CACC,GAAI8C,IAAKrD,MAAM,CACXO,OAASwC,IAAI3C,SAAU,GAAGG,OAAQ2C,KAAKG,GAAIA,EAAGL,eAE7CK,IAET,OAAO9C,SAOf,IAAKK,MAAMhB,UAAUiE,UAAa,EAAG,GAAGA,QAAQ,EAAG,KAAO,EAAI,CAC1DjD,MAAMhB,UAAUiE,QAAU,QAASA,SAAQC,QACvC,GAAIZ,MAAOL,aAAe9B,UAAUf,OAAS,kBACrCA,KAAKmD,MAAM,IACXF,SAASjD,MACb6B,OAASqB,KAAKrB,SAAW,CAE7B,KAAKA,OAAQ,CACT,OAAQ,EAGZ,GAAIwB,GAAI,CACR,IAAIhD,UAAUwB,OAAS,EAAG,CACtBwB,EAAIU,UAAU1D,UAAU,IAI5BgD,EAAIA,GAAK,EAAIA,EAAIW,KAAKC,IAAI,EAAGpC,OAASwB,EACtC,MAAOA,EAAIxB,OAAQwB,IAAK,CACpB,GAAIA,IAAKH,OAAQA,KAAKG,KAAOS,OAAQ,CACjC,MAAOT,IAGf,OAAQ,GAOhB,IAAKzC,MAAMhB,UAAUsE,cAAiB,EAAG,GAAGA,YAAY,GAAI,KAAO,EAAI,CACnEtD,MAAMhB,UAAUsE,YAAc,QAASA,aAAYJ,QAC/C,GAAIZ,MAAOL,aAAe9B,UAAUf,OAAS,kBACrCA,KAAKmD,MAAM,IACXF,SAASjD,MACb6B,OAASqB,KAAKrB,SAAW,CAE7B,KAAKA,OAAQ,CACT,OAAQ,EAEZ,GAAIwB,GAAIxB,OAAS,CACjB,IAAIxB,UAAUwB,OAAS,EAAG,CACtBwB,EAAIW,KAAKG,IAAId,EAAGU,UAAU1D,UAAU,KAGxCgD,EAAIA,GAAK,EAAIA,EAAIxB,OAASmC,KAAKI,IAAIf,EACnC,MAAOA,GAAK,EAAGA,IAAK,CAChB,GAAIA,IAAKH,OAAQY,SAAWZ,KAAKG,GAAI,CACjC,MAAOA,IAGf,OAAQ,GAWhB,IAAK3C,OAAO2D,KAAM,CAEd,GAAIC,gBAAiB,KACjBC,WACI,WACA,iBACA,UACA,iBACA,gBACA,uBACA,eAEJC,gBAAkBD,UAAU1C,MAEhC,KAAK,GAAI4C,QAAQzD,SAAY,MAAO,CAChCsD,eAAiB,MAGrB5D,OAAO2D,KAAO,QAASA,MAAKrB,QAExB,SACYA,SAAU,gBAAmBA,SAAU,YAC/CA,SAAW,KACb,CACE,KAAM,IAAI/C,WAAU,sCAGxB,GAAIoE,QACJ,KAAK,GAAIK,QAAQ1B,QAAQ,CACrB,GAAI/B,KAAK+B,OAAQ0B,MAAO,CACpBL,KAAK7B,KAAKkC,OAIlB,GAAIJ,eAAgB,CAChB,IAAK,GAAIjB,GAAI,EAAGsB,GAAKH,gBAAiBnB,EAAIsB,GAAItB,IAAK,CAC/C,GAAIuB,UAAWL,UAAUlB,EACzB,IAAIpC,KAAK+B,OAAQ4B,UAAW,CACxBP,KAAK7B,KAAKoC,YAItB,MAAOP,OAiBf,GAAIQ,eAAgB,YAChBC,mBAAqB,SACzB,KACKC,KAAKnF,UAAUoF,aACf,GAAID,MAAKF,cAAcG,cAAcnB,QAAQiB,uBAAyB,EACzE,CACEC,KAAKnF,UAAUoF,YAAc,QAASA,eAClC,GAAIzE,QAAQsB,OAAQ2B,MAAOyB,KAAMC,KACjC,KAAKC,SAASnF,MAAO,CACjB,KAAM,IAAIoF,YAAW,0DAGzBH,KAAOjF,KAAKqF,gBAEZH,OAAQlF,KAAKsF,aAEbL,OAAQjB,KAAKuB,MAAML,MAAQ,GAC3BA,QAASA,MAAQ,GAAK,IAAM,EAG5B3E,SAAU2E,MAAQ,EAAGlF,KAAKwF,aACtBxF,KAAKyF,cAAezF,KAAK0F,gBAAiB1F,KAAK2F,gBACnDV,OACKA,KAAO,EAAI,IAAOA,KAAO,KAAO,IAAM,KACtC,QAAUjB,KAAKI,IAAIa,OACnBnE,MAAM,GAAKmE,MAAQA,MAAQ,MAAQ,GAAK,EAG7CpD,QAAStB,OAAOsB,MAChB,OAAOA,SAAU,CACb2B,MAAQjD,OAAOsB,OAGf,IAAI2B,MAAQ,GAAI,CACZjD,OAAOsB,QAAU,IAAM2B,OAI/B,MACIyB,MAAO,IAAM1E,OAAOO,MAAM,EAAG,GAAG8E,KAAK,KACrC,IAAMrF,OAAOO,MAAM,GAAG8E,KAAK,KAAO,KACjC,MAAQ5F,KAAK6F,sBAAsB/E,OAAO,GAAK,KAU5D,GAAIgF,uBAAwB,KAC5B,KACIA,sBACIf,KAAKnF,UAAUmG,QACf,GAAIhB,MAAKiB,KAAKD,WAAa,MAC3B,GAAIhB,MAAKF,cAAckB,SAASlC,QAAQiB,uBAAyB,GACjEC,KAAKnF,UAAUmG,OAAO3F,MAClB4E,YAAa,WACT,MAAO,SAIrB,MAAOiB,IAET,IAAKH,sBAAuB,CACxBf,KAAKnF,UAAUmG,OAAS,QAASA,QAAOtB,KAOpC,GAAIyB,GAAIxF,OAAOV,MACXmG,GAAKC,YAAYF,GACjBG,KAEJ,UAAWF,MAAO,WAAahB,SAASgB,IAAK,CACzC,MAAO,MAIXE,MAAQH,EAAElB,WAEV,UAAWqB,QAAS,WAAY,CAC5B,KAAM,IAAIpG,WAAU,wCAIxB,MAAOoG,OAAMjG,KAAK8F,IAiB1B,IAAKnB,KAAKuB,OAAS,sBAAuB,CAGtCvB,KAAO,SAAUwB,YAGb,QAASxB,MAAKyB,EAAGC,EAAGC,EAAGC,EAAGC,EAAGC,EAAGC,IAC5B,GAAIjF,QAASxB,UAAUwB,MACvB,IAAI7B,eAAgBuG,YAAY,CAC5B,GAAIQ,MAAOlF,QAAU,GAAKmF,OAAOR,KAAOA,EAEpC,GAAID,YAAWxB,KAAKuB,MAAME,IAG1B3E,QAAU,EAAI,GAAI0E,YAAWC,EAAGC,EAAGC,EAAGC,EAAGC,EAAGC,EAAGC,IAC/CjF,QAAU,EAAI,GAAI0E,YAAWC,EAAGC,EAAGC,EAAGC,EAAGC,EAAGC,GAC5ChF,QAAU,EAAI,GAAI0E,YAAWC,EAAGC,EAAGC,EAAGC,EAAGC,GACzC/E,QAAU,EAAI,GAAI0E,YAAWC,EAAGC,EAAGC,EAAGC,GACtC9E,QAAU,EAAI,GAAI0E,YAAWC,EAAGC,EAAGC,GACnC7E,QAAU,EAAI,GAAI0E,YAAWC,EAAGC,GAChC5E,QAAU,EAAI,GAAI0E,YAAWC,GACf,GAAID,WAEtBQ,MAAKE,YAAclC,IACnB,OAAOgC,MAEX,MAAOR,YAAW/F,MAAMR,KAAMK,WAIlC,GAAI6G,mBAAoB,GAAIC,QAAO,IAC/B,sBAEA,eACA,eACA,MACI,YACA,YACA,MACI,YACA,oBACJ,KACJ,IACI,KACA,MACI,SACA,WACA,YACJ,IACJ,WACJ,IAEA,IAAIC,SACA,EAAG,GAAI,GAAI,GAAI,IAAK,IAAK,IAAK,IAAK,IAAK,IAAK,IAAK,IAAK,IAG3D,SAASC,cAAapC,KAAMC,OACxB,GAAIoC,GAAIpC,MAAQ,EAAI,EAAI,CACxB,OACIkC,QAAOlC,OACPlB,KAAKuB,OAAON,KAAO,KAAOqC,GAAK,GAC/BtD,KAAKuB,OAAON,KAAO,KAAOqC,GAAK,KAC/BtD,KAAKuB,OAAON,KAAO,KAAOqC,GAAK,KAC/B,KAAOrC,KAAO,MAKtB,IAAK,GAAIR,OAAO8B,YAAY,CACxBxB,KAAKN,KAAO8B,WAAW9B,KAI3BM,KAAKwC,IAAMhB,WAAWgB,GACtBxC,MAAKyC,IAAMjB,WAAWiB,GACtBzC,MAAKnF,UAAY2G,WAAW3G,SAC5BmF,MAAKnF,UAAUqH,YAAclC,IAG7BA,MAAKuB,MAAQ,QAASA,OAAMmB,QACxB,GAAIC,OAAQR,kBAAkBS,KAAKF,OACnC,IAAIC,MAAO,CAIP,GAAIzC,MAAO2C,OAAOF,MAAM,IACpBxC,MAAQ0C,OAAOF,MAAM,IAAM,GAAK,EAChCG,IAAMD,OAAOF,MAAM,IAAM,GAAK,EAC9BI,KAAOF,OAAOF,MAAM,IAAM,GAC1BK,OAASH,OAAOF,MAAM,IAAM,GAC5BM,OAASJ,OAAOF,MAAM,IAAM,GAC5BO,YAAcjE,KAAKuB,MAAMqC,OAAOF,MAAM,IAAM,GAAK,KAIjDQ,QAAUR,MAAM,IAAMA,MAAM,GACxB,EAAIE,OAAO,GAAIrB,YAAW,KAAM,IACpC4B,WAAaT,MAAM,KAAO,IAAM,GAAK,EACrCU,WAAaR,OAAOF,MAAM,KAAO,GACjCW,aAAeT,OAAOF,MAAM,KAAO,GACnCnH,MACJ,IACIuH,MACIC,OAAS,GAAKC,OAAS,GAAKC,YAAc,EAC1C,GAAK,KAETF,OAAS,IAAMC,OAAS,IAAMC,YAAc,KAC5C/C,OAAS,GAAKA,MAAQ,IAAMkD,WAAa,IACzCC,aAAe,IACfR,KAAO,GACPA,IACIR,aAAapC,KAAMC,MAAQ,GAC3BmC,aAAapC,KAAMC,OAEzB,CACE3E,SACK8G,aAAapC,KAAMC,OAAS2C,KAAO,GACpCC,KACAM,WAAaD,YACb,EACJ5H,UACKA,OAASwH,OAASM,aAAeF,YAAc,GAChDH,QACA,IAAOC,YAAcC,MACzB,KAAK,QAAW3H,QAAUA,QAAU,OAAS,CACzC,MAAOA,SAGf,MAAOyF,KAEX,MAAOO,YAAWD,MAAM9F,MAAMR,KAAMK,WAGxC,OAAO0E,OACRA,MAKP,IAAKA,KAAKwC,IAAK,CACXxC,KAAKwC,IAAM,QAASA,OAChB,OAAO,GAAIxC,OAAOuD,WAY1B,IAAKV,OAAOhI,UAAU2I,SAAW,KAAUA,QAAQ,KAAO,SAAW,GAAMA,QAAQ,KAAO,KAAO,MAAQA,QAAQ,KAAO,QAAU,kBAAsBA,QAAQ,KAAO,sBAAuB,EAEzL,WACG,GAAIC,MAAMC,KAAMC,KAAMrF,CAEtBmF,MAAO,GACPC,MAAO,CACPC,OAAQ,EAAG,EAAG,EAAG,EAAG,EAAG,EAEvB,SAASC,UAASC,EAAGC,GACjB,GAAIxF,IAAK,CACT,SAASA,EAAIoF,KAAM,CACfI,GAAKD,EAAIF,KAAKrF,EACdqF,MAAKrF,GAAKwF,EAAIL,IACdK,GAAI7E,KAAKuB,MAAMsD,EAAIL,OAI3B,QAASM,QAAOF,GACZ,GAAIvF,GAAIoF,KAAMI,EAAI,CAClB,SAASxF,GAAK,EAAG,CACbwF,GAAKH,KAAKrF,EACVqF,MAAKrF,GAAKW,KAAKuB,MAAMsD,EAAID,EACzBC,GAAKA,EAAID,EAAKJ,MAItB,QAASxH,YACL,GAAIqC,GAAIoF,IACR,IAAI5B,GAAI,EACR,SAASxD,GAAK,EAAG,CACb,GAAIwD,IAAM,IAAMxD,IAAM,GAAKqF,KAAKrF,KAAO,EAAG,CACtC,GAAIiE,GAAIN,OAAO0B,KAAKrF,GACpB,IAAIwD,IAAM,GAAI,CACVA,EAAIS,MACD,CACHT,GAAK,UAAU/F,MAAM,EAAG,EAAIwG,EAAEzF,QAAUyF,IAIpD,MAAOT,GAGX,QAASkC,KAAIC,EAAGJ,EAAGK,KACf,MAAQL,KAAM,EAAIK,IAAOL,EAAI,IAAM,EAAIG,IAAIC,EAAGJ,EAAI,EAAGK,IAAMD,GAAKD,IAAIC,EAAIA,EAAGJ,EAAI,EAAGK,KAGtF,QAASC,KAAIF,GACT,GAAIJ,GAAI,CACR,OAAOI,GAAK,KAAM,CACdJ,GAAK,EACLI,IAAK,KAET,MAAOA,GAAK,EAAG,CACXJ,GAAK,CACLI,IAAK,EAET,MAAOJ,GAGXhB,OAAOhI,UAAU2I,QAAU,SAAUY,gBACjC,GAAIC,GAAGJ,EAAGnC,EAAGD,EAAGX,EAAGoD,EAAGC,EAAGC,CAGzBH,GAAIxB,OAAOuB,eACXC,GAAIA,IAAMA,EAAI,EAAIpF,KAAKuB,MAAM6D,EAE7B,IAAIA,EAAI,GAAKA,EAAI,GAAI,CACjB,KAAM,IAAIhE,YAAW,yDAGzB4D,EAAIpB,OAAO5H,KAGX,IAAIgJ,IAAMA,EAAG,CACT,MAAO,MAIX,GAAIA,IAAM,MAAQA,GAAK,KAAM,CACzB,MAAOhC,QAAOgC,GAGlBnC,EAAI,EAEJ,IAAImC,EAAI,EAAG,CACPnC,EAAI,GACJmC,IAAKA,EAGTpC,EAAI,GAEJ,IAAIoC,EAAI,MAAO,CAGX/C,EAAIiD,IAAIF,EAAID,IAAI,EAAG,GAAI,IAAM,EAC7BM,GAAKpD,EAAI,EAAI+C,EAAID,IAAI,GAAI9C,EAAG,GAAK+C,EAAID,IAAI,EAAG9C,EAAG,EAC/CoD,IAAK,gBACLpD,GAAI,GAAKA,CAIT,IAAIA,EAAI,EAAG,CACP0C,SAAS,EAAGU,EACZC,GAAIF,CAEJ,OAAOE,GAAK,EAAG,CACXX,SAAS,IAAK,EACdW,IAAK,EAGTX,SAASI,IAAI,GAAIO,EAAG,GAAI,EACxBA,GAAIrD,EAAI,CAER,OAAOqD,GAAK,GAAI,CACZR,OAAO,GAAK,GACZQ,IAAK,GAGTR,OAAO,GAAKQ,EACZX,UAAS,EAAG,EACZG,QAAO,EACPlC,GAAI5F,eACD,CACH2H,SAAS,EAAGU,EACZV,UAAS,IAAO1C,EAAI,EACpBW,GAAI5F,WAAa,yBAAyBF,MAAM,EAAG,EAAIsI,IAI/D,GAAIA,EAAI,EAAG,CACPG,EAAI3C,EAAE/E,MAEN,IAAI0H,GAAKH,EAAG,CACRxC,EAAIC,EAAI,wBAAwB/F,MAAM,EAAGsI,EAAIG,EAAI,GAAK3C,MACnD,CACHA,EAAIC,EAAID,EAAE9F,MAAM,EAAGyI,EAAIH,GAAK,IAAMxC,EAAE9F,MAAMyI,EAAIH,QAE/C,CACHxC,EAAIC,EAAID,EAGZ,MAAOA,QA2BnB,GAAI4C,cAAexC,OAAOpH,UAAUuD,KACpC,IACI,KAAKA,MAAM,WAAWtB,SAAW,GACjC,IAAIsB,MAAM,YAAYtB,SAAW,GACjC,QAAQsB,MAAM,QAAQ,KAAO,KAC7B,GAAGA,MAAM,MAAMtB,SAAW,GAC1B,IAAIsB,MAAM,QAAQtB,OAAS,EAC7B,EACG,WACG,GAAI4H,mBAAoB,OAAO9B,KAAK,IAAI,SAAY,EAEpDX,QAAOpH,UAAUuD,MAAQ,SAAUuG,UAAWC,OAC1C,GAAIlC,QAASzH,IACb,IAAI0J,gBAAmB,IAAKC,QAAU,EAClC,QAGJ,IAAIjJ,OAAOd,UAAUoB,SAASZ,KAAKsJ,aAAe,kBAAmB,CACjE,MAAOF,cAAahJ,MAAMR,KAAMK,WAGpC,GAAIuJ,WACAC,OAASH,UAAUI,WAAa,IAAM,KAC7BJ,UAAUK,UAAa,IAAM,KAC7BL,UAAUM,SAAa,IAAM,KAC7BN,UAAUO,OAAa,IAAM,IACtCC,cAAgB,EAEhBR,UAAY,GAAIvC,QAAOuC,UAAUS,OAAQN,MAAQ,KACjDO,WAAY1C,MAAO2C,UAAWC,UAClC7C,SAAU,EACV,KAAKgC,kBAAmB,CAEpBW,WAAa,GAAIjD,QAAO,IAAMuC,UAAUS,OAAS,WAAYN,OASjEF,MAAQA,YAAe,IAClB,IAAM,EACPA,QAAU,CACd,OAAOjC,MAAQgC,UAAU/B,KAAKF,QAAS,CAEnC4C,UAAY3C,MAAM6C,MAAQ7C,MAAM,GAAG7F,MACnC,IAAIwI,UAAYH,cAAe,CAC3BN,OAAOpH,KAAKiF,OAAO3G,MAAMoJ,cAAexC,MAAM6C,OAG9C,KAAKd,mBAAqB/B,MAAM7F,OAAS,EAAG,CACxC6F,MAAM,GAAG8C,QAAQJ,WAAY,WACzB,IAAK,GAAI/G,GAAI,EAAGA,EAAIhD,UAAUwB,OAAS,EAAGwB,IAAK,CAC3C,GAAIhD,UAAUgD,SAAY,GAAG,CACzBqE,MAAMrE,OAAU,OAKhC,GAAIqE,MAAM7F,OAAS,GAAK6F,MAAM6C,MAAQ9C,OAAO5F,OAAQ,CACjDjB,MAAMhB,UAAU4C,KAAKhC,MAAMoJ,OAAQlC,MAAM5G,MAAM,IAEnDwJ,WAAa5C,MAAM,GAAG7F,MACtBqI,eAAgBG,SAChB,IAAIT,OAAO/H,QAAU8H,MAAO,CACxB,OAGR,GAAID,UAAUW,YAAc3C,MAAM6C,MAAO,CACrCb,UAAUW,aAGlB,GAAIH,gBAAkBzC,OAAO5F,OAAQ,CACjC,GAAIyI,aAAeZ,UAAUe,KAAK,IAAK,CACnCb,OAAOpH,KAAK,SAEb,CACHoH,OAAOpH,KAAKiF,OAAO3G,MAAMoJ,gBAE7B,MAAON,QAAO/H,OAAS8H,MAAQC,OAAO9I,MAAM,EAAG6I,OAASC,gBAU7D,IAAI,IAAIzG,UAAW,GAAG,GAAGtB,OAAQ,CACpCmF,OAAOpH,UAAUuD,MAAQ,SAASuG,UAAWC,OACzC,GAAID,gBAAmB,IAAKC,QAAU,EAAG,QACzC,OAAOH,cAAahJ,MAAMR,KAAMK,YAUxC,GAAG,GAAGqK,QAAU,KAAKA,QAAQ,KAAO,IAAK,CACrC,GAAIC,eAAgB3D,OAAOpH,UAAU8K,MAOrC1D,QAAOpH,UAAU8K,OAAS,SAASrI,MAAOR,QACtC,MAAO8I,eAAcvK,KACjBJ,KACAqC,MAAQ,GAAMA,MAAQrC,KAAK6B,OAASQ,OAAS,EAAI,EAAIA,MAASA,MAC9DR,SAOZ,GAAI+I,IAAK,oDACL,qEACA,cACJ,KAAK5D,OAAOpH,UAAUiL,MAAQD,GAAGC,OAAQ,CAGrCD,GAAK,IAAMA,GAAK,GAChB,IAAIE,iBAAkB,GAAI3D,QAAO,IAAMyD,GAAKA,GAAK,KAC7CG,cAAgB,GAAI5D,QAAOyD,GAAKA,GAAK,KACzC5D,QAAOpH,UAAUiL,KAAO,QAASA,QAC7B,GAAI7K,WAAc,IAAKA,OAAS,KAAM,CAClC,KAAM,IAAIC,WAAU,iBAAiBD,KAAK,cAE9C,MAAOgH,QAAOhH,MACTwK,QAAQM,gBAAiB,IACzBN,QAAQO,cAAe,KAapC,QAAShH,WAAU6E,GACfA,GAAKA,CACL,IAAIA,IAAMA,EAAG,CACTA,EAAI,MACD,IAAIA,IAAM,GAAKA,IAAO,EAAE,GAAMA,MAAQ,EAAE,GAAI,CAC/CA,GAAKA,EAAI,IAAM,GAAK5E,KAAKuB,MAAMvB,KAAKI,IAAIwE,IAE5C,MAAOA,GAGX,QAASoC,aAAYC,OACjB,GAAIC,YAAcD,MAClB,OACIA,SAAU,MACVC,OAAS,aACTA,OAAS,WACTA,OAAS,UACTA,OAAS,SAIjB,QAAS9E,aAAY6E,OACjB,GAAIE,KAAKC,QAASpK,QAClB,IAAIgK,YAAYC,OAAQ,CACpB,MAAOA,OAEXG,QAAUH,MAAMG,OAChB,UAAWA,WAAY,WAAY,CAC/BD,IAAMC,QAAQhL,KAAK6K,MACnB,IAAID,YAAYG,KAAM,CAClB,MAAOA,MAGfnK,SAAWiK,MAAMjK,QACjB,UAAWA,YAAa,WAAY,CAChCmK,IAAMnK,SAASZ,KAAK6K,MACpB,IAAID,YAAYG,KAAM,CAClB,MAAOA,MAGf,KAAM,IAAIlL,WAKd,GAAIgD,UAAW,SAAUiD,GACrB,GAAIA,GAAK,KAAM,CACX,KAAM,IAAIjG,WAAU,iBAAiBiG,EAAE,cAE3C,MAAOxF,QAAOwF"} \ No newline at end of file
diff --git a/addons/web/static/lib/es5-shim/es5-shim.min.js b/addons/web/static/lib/es5-shim/es5-shim.min.js
deleted file mode 100644
index 5d265c55a9b..00000000000
--- a/addons/web/static/lib/es5-shim/es5-shim.min.js
+++ /dev/null
@@ -1,4 +0,0 @@
-(function(definition){if(typeof define=="function"){define(definition)}else if(typeof YUI=="function"){YUI.add("es5",definition)}else{definition()}})(function(){function Empty(){}if(!Function.prototype.bind){Function.prototype.bind=function bind(that){var target=this;if(typeof target!="function"){throw new TypeError("Function.prototype.bind called on incompatible "+target)}var args=_Array_slice_.call(arguments,1);var bound=function(){if(this instanceof bound){var result=target.apply(this,args.concat(_Array_slice_.call(arguments)));if(Object(result)===result){return result}return this}else{return target.apply(that,args.concat(_Array_slice_.call(arguments)))}};if(target.prototype){Empty.prototype=target.prototype;bound.prototype=new Empty;Empty.prototype=null}return bound}}var call=Function.prototype.call;var prototypeOfArray=Array.prototype;var prototypeOfObject=Object.prototype;var _Array_slice_=prototypeOfArray.slice;var _toString=call.bind(prototypeOfObject.toString);var owns=call.bind(prototypeOfObject.hasOwnProperty);var defineGetter;var defineSetter;var lookupGetter;var lookupSetter;var supportsAccessors;if(supportsAccessors=owns(prototypeOfObject,"__defineGetter__")){defineGetter=call.bind(prototypeOfObject.__defineGetter__);defineSetter=call.bind(prototypeOfObject.__defineSetter__);lookupGetter=call.bind(prototypeOfObject.__lookupGetter__);lookupSetter=call.bind(prototypeOfObject.__lookupSetter__)}if([1,2].splice(0).length!=2){var array_splice=Array.prototype.splice;if(function(){function makeArray(l){var a=[];while(l--){a.unshift(l)}return a}var array=[],lengthBefore;array.splice.bind(array,0,0).apply(null,makeArray(20));array.splice.bind(array,0,0).apply(null,makeArray(26));lengthBefore=array.length;array.splice(5,0,"XXX");if(lengthBefore+1==array.length){return true}}()){Array.prototype.splice=function(start,deleteCount){if(!arguments.length){return[]}else{return array_splice.apply(this,[start===void 0?0:start,deleteCount===void 0?this.length-start:deleteCount].concat(_Array_slice_.call(arguments,2)))}}}else{Array.prototype.splice=function(start,deleteCount){var result,args=_Array_slice_.call(arguments,2),addElementsCount=args.length;if(!arguments.length){return[]}if(start===void 0){start=0}if(deleteCount===void 0){deleteCount=this.length-start}if(addElementsCount>0){if(deleteCount<=0){if(start==this.length){this.push.apply(this,args);return[]}if(start==0){this.unshift.apply(this,args);return[]}}result=_Array_slice_.call(this,start,start+deleteCount);args.push.apply(args,_Array_slice_.call(this,start+deleteCount,this.length));args.unshift.apply(args,_Array_slice_.call(this,0,start));args.unshift(0,this.length);array_splice.apply(this,args);return result}return array_splice.call(this,start,deleteCount)}}}if([].unshift(0)!=1){var array_unshift=Array.prototype.unshift;Array.prototype.unshift=function(){array_unshift.apply(this,arguments);return this.length}}if(!Array.isArray){Array.isArray=function isArray(obj){return _toString(obj)=="[object Array]"}}var boxedString=Object("a"),splitString=boxedString[0]!="a"||!(0 in boxedString);if(!Array.prototype.forEach){Array.prototype.forEach=function forEach(fun){var object=toObject(this),self=splitString&&_toString(this)=="[object String]"?this.split(""):object,thisp=arguments[1],i=-1,length=self.length>>>0;if(_toString(fun)!="[object Function]"){throw new TypeError}while(++i<length){if(i in self){fun.call(thisp,self[i],i,object)}}}}if(!Array.prototype.map){Array.prototype.map=function map(fun){var object=toObject(this),self=splitString&&_toString(this)=="[object String]"?this.split(""):object,length=self.length>>>0,result=Array(length),thisp=arguments[1];if(_toString(fun)!="[object Function]"){throw new TypeError(fun+" is not a function")}for(var i=0;i<length;i++){if(i in self)result[i]=fun.call(thisp,self[i],i,object)}return result}}if(!Array.prototype.filter){Array.prototype.filter=function filter(fun){var object=toObject(this),self=splitString&&_toString(this)=="[object String]"?this.split(""):object,length=self.length>>>0,result=[],value,thisp=arguments[1];if(_toString(fun)!="[object Function]"){throw new TypeError(fun+" is not a function")}for(var i=0;i<length;i++){if(i in self){value=self[i];if(fun.call(thisp,value,i,object)){result.push(value)}}}return result}}if(!Array.prototype.every){Array.prototype.every=function every(fun){var object=toObject(this),self=splitString&&_toString(this)=="[object String]"?this.split(""):object,length=self.length>>>0,thisp=arguments[1];if(_toString(fun)!="[object Function]"){throw new TypeError(fun+" is not a function")}for(var i=0;i<length;i++){if(i in self&&!fun.call(thisp,self[i],i,object)){return false}}return true}}if(!Array.prototype.some){Array.prototype.some=function some(fun){var object=toObject(this),self=splitString&&_toString(this)=="[object String]"?this.split(""):object,length=self.length>>>0,thisp=arguments[1];if(_toString(fun)!="[object Function]"){throw new TypeError(fun+" is not a function")}for(var i=0;i<length;i++){if(i in self&&fun.call(thisp,self[i],i,object)){return true}}return false}}if(!Array.prototype.reduce){Array.prototype.reduce=function reduce(fun){var object=toObject(this),self=splitString&&_toString(this)=="[object String]"?this.split(""):object,length=self.length>>>0;if(_toString(fun)!="[object Function]"){throw new TypeError(fun+" is not a function")}if(!length&&arguments.length==1){throw new TypeError("reduce of empty array with no initial value")}var i=0;var result;if(arguments.length>=2){result=arguments[1]}else{do{if(i in self){result=self[i++];break}if(++i>=length){throw new TypeError("reduce of empty array with no initial value")}}while(true)}for(;i<length;i++){if(i in self){result=fun.call(void 0,result,self[i],i,object)}}return result}}if(!Array.prototype.reduceRight){Array.prototype.reduceRight=function reduceRight(fun){var object=toObject(this),self=splitString&&_toString(this)=="[object String]"?this.split(""):object,length=self.length>>>0;if(_toString(fun)!="[object Function]"){throw new TypeError(fun+" is not a function")}if(!length&&arguments.length==1){throw new TypeError("reduceRight of empty array with no initial value")}var result,i=length-1;if(arguments.length>=2){result=arguments[1]}else{do{if(i in self){result=self[i--];break}if(--i<0){throw new TypeError("reduceRight of empty array with no initial value")}}while(true)}if(i<0){return result}do{if(i in this){result=fun.call(void 0,result,self[i],i,object)}}while(i--);return result}}if(!Array.prototype.indexOf||[0,1].indexOf(1,2)!=-1){Array.prototype.indexOf=function indexOf(sought){var self=splitString&&_toString(this)=="[object String]"?this.split(""):toObject(this),length=self.length>>>0;if(!length){return-1}var i=0;if(arguments.length>1){i=toInteger(arguments[1])}i=i>=0?i:Math.max(0,length+i);for(;i<length;i++){if(i in self&&self[i]===sought){return i}}return-1}}if(!Array.prototype.lastIndexOf||[0,1].lastIndexOf(0,-3)!=-1){Array.prototype.lastIndexOf=function lastIndexOf(sought){var self=splitString&&_toString(this)=="[object String]"?this.split(""):toObject(this),length=self.length>>>0;if(!length){return-1}var i=length-1;if(arguments.length>1){i=Math.min(i,toInteger(arguments[1]))}i=i>=0?i:length-Math.abs(i);for(;i>=0;i--){if(i in self&&sought===self[i]){return i}}return-1}}if(!Object.keys){var hasDontEnumBug=true,dontEnums=["toString","toLocaleString","valueOf","hasOwnProperty","isPrototypeOf","propertyIsEnumerable","constructor"],dontEnumsLength=dontEnums.length;for(var key in{toString:null}){hasDontEnumBug=false}Object.keys=function keys(object){if(typeof object!="object"&&typeof object!="function"||object===null){throw new TypeError("Object.keys called on a non-object")}var keys=[];for(var name in object){if(owns(object,name)){keys.push(name)}}if(hasDontEnumBug){for(var i=0,ii=dontEnumsLength;i<ii;i++){var dontEnum=dontEnums[i];if(owns(object,dontEnum)){keys.push(dontEnum)}}}return keys}}var negativeDate=-621987552e5,negativeYearString="-000001";if(!Date.prototype.toISOString||new Date(negativeDate).toISOString().indexOf(negativeYearString)===-1){Date.prototype.toISOString=function toISOString(){var result,length,value,year,month;if(!isFinite(this)){throw new RangeError("Date.prototype.toISOString called on non-finite value.")}year=this.getUTCFullYear();month=this.getUTCMonth();year+=Math.floor(month/12);month=(month%12+12)%12;result=[month+1,this.getUTCDate(),this.getUTCHours(),this.getUTCMinutes(),this.getUTCSeconds()];year=(year<0?"-":year>9999?"+":"")+("00000"+Math.abs(year)).slice(0<=year&&year<=9999?-4:-6);length=result.length;while(length--){value=result[length];if(value<10){result[length]="0"+value}}return year+"-"+result.slice(0,2).join("-")+"T"+result.slice(2).join(":")+"."+("000"+this.getUTCMilliseconds()).slice(-3)+"Z"}}var dateToJSONIsSupported=false;try{dateToJSONIsSupported=Date.prototype.toJSON&&new Date(NaN).toJSON()===null&&new Date(negativeDate).toJSON().indexOf(negativeYearString)!==-1&&Date.prototype.toJSON.call({toISOString:function(){return true}})}catch(e){}if(!dateToJSONIsSupported){Date.prototype.toJSON=function toJSON(key){var o=Object(this),tv=toPrimitive(o),toISO;if(typeof tv==="number"&&!isFinite(tv)){return null}toISO=o.toISOString;if(typeof toISO!="function"){throw new TypeError("toISOString property is not callable")}return toISO.call(o)}}if(!Date.parse||"Date.parse is buggy"){Date=function(NativeDate){function Date(Y,M,D,h,m,s,ms){var length=arguments.length;if(this instanceof NativeDate){var date=length==1&&String(Y)===Y?new NativeDate(Date.parse(Y)):length>=7?new NativeDate(Y,M,D,h,m,s,ms):length>=6?new NativeDate(Y,M,D,h,m,s):length>=5?new NativeDate(Y,M,D,h,m):length>=4?new NativeDate(Y,M,D,h):length>=3?new NativeDate(Y,M,D):length>=2?new NativeDate(Y,M):length>=1?new NativeDate(Y):new NativeDate;date.constructor=Date;return date}return NativeDate.apply(this,arguments)}var isoDateExpression=new RegExp("^"+"(\\d{4}|[+-]\\d{6})"+"(?:-(\\d{2})"+"(?:-(\\d{2})"+"(?:"+"T(\\d{2})"+":(\\d{2})"+"(?:"+":(\\d{2})"+"(?:(\\.\\d{1,}))?"+")?"+"("+"Z|"+"(?:"+"([-+])"+"(\\d{2})"+":(\\d{2})"+")"+")?)?)?)?"+"$");var months=[0,31,59,90,120,151,181,212,243,273,304,334,365];function dayFromMonth(year,month){var t=month>1?1:0;return months[month]+Math.floor((year-1969+t)/4)-Math.floor((year-1901+t)/100)+Math.floor((year-1601+t)/400)+365*(year-1970)}for(var key in NativeDate){Date[key]=NativeDate[key]}Date.now=NativeDate.now;Date.UTC=NativeDate.UTC;Date.prototype=NativeDate.prototype;Date.prototype.constructor=Date;Date.parse=function parse(string){var match=isoDateExpression.exec(string);if(match){var year=Number(match[1]),month=Number(match[2]||1)-1,day=Number(match[3]||1)-1,hour=Number(match[4]||0),minute=Number(match[5]||0),second=Number(match[6]||0),millisecond=Math.floor(Number(match[7]||0)*1e3),offset=!match[4]||match[8]?0:Number(new NativeDate(1970,0)),signOffset=match[9]==="-"?1:-1,hourOffset=Number(match[10]||0),minuteOffset=Number(match[11]||0),result;if(hour<(minute>0||second>0||millisecond>0?24:25)&&minute<60&&second<60&&millisecond<1e3&&month>-1&&month<12&&hourOffset<24&&minuteOffset<60&&day>-1&&day<dayFromMonth(year,month+1)-dayFromMonth(year,month)){result=((dayFromMonth(year,month)+day)*24+hour+hourOffset*signOffset)*60;result=((result+minute+minuteOffset*signOffset)*60+second)*1e3+millisecond+offset;if(-864e13<=result&&result<=864e13){return result}}return NaN}return NativeDate.parse.apply(this,arguments)};return Date}(Date)}if(!Date.now){Date.now=function now(){return(new Date).getTime()}}if(!Number.prototype.toFixed||8e-5.toFixed(3)!=="0.000"||.9.toFixed(0)==="0"||1.255.toFixed(2)!=="1.25"||0xde0b6b3a7640080.toFixed(0)!=="1000000000000000128"){(function(){var base,size,data,i;base=1e7;size=6;data=[0,0,0,0,0,0];function multiply(n,c){var i=-1;while(++i<size){c+=n*data[i];data[i]=c%base;c=Math.floor(c/base)}}function divide(n){var i=size,c=0;while(--i>=0){c+=data[i];data[i]=Math.floor(c/n);c=c%n*base}}function toString(){var i=size;var s="";while(--i>=0){if(s!==""||i===0||data[i]!==0){var t=String(data[i]);if(s===""){s=t}else{s+="0000000".slice(0,7-t.length)+t}}}return s}function pow(x,n,acc){return n===0?acc:n%2===1?pow(x,n-1,acc*x):pow(x*x,n/2,acc)}function log(x){var n=0;while(x>=4096){n+=12;x/=4096}while(x>=2){n+=1;x/=2}return n}Number.prototype.toFixed=function(fractionDigits){var f,x,s,m,e,z,j,k;f=Number(fractionDigits);f=f!==f?0:Math.floor(f);if(f<0||f>20){throw new RangeError("Number.toFixed called with invalid number of decimals")}x=Number(this);if(x!==x){return"NaN"}if(x<=-1e21||x>=1e21){return String(x)}s="";if(x<0){s="-";x=-x}m="0";if(x>1e-21){e=log(x*pow(2,69,1))-69;z=e<0?x*pow(2,-e,1):x/pow(2,e,1);z*=4503599627370496;e=52-e;if(e>0){multiply(0,z);j=f;while(j>=7){multiply(1e7,0);j-=7}multiply(pow(10,j,1),0);j=e-1;while(j>=23){divide(1<<23);j-=23}divide(1<<j);multiply(1,1);divide(2);m=toString()}else{multiply(0,z);multiply(1<<-e,0);m=toString()+"0.00000000000000000000".slice(2,2+f)}}if(f>0){k=m.length;if(k<=f){m=s+"0.0000000000000000000".slice(0,f-k+2)+m}else{m=s+m.slice(0,k-f)+"."+m.slice(k-f)}}else{m=s+m}return m}})()}var string_split=String.prototype.split;if("ab".split(/(?:ab)*/).length!==2||".".split(/(.?)(.?)/).length!==4||"tesst".split(/(s)*/)[1]==="t"||"".split(/.?/).length===0||".".split(/()()/).length>1){(function(){var compliantExecNpcg=/()??/.exec("")[1]===void 0;String.prototype.split=function(separator,limit){var string=this;if(separator===void 0&&limit===0)return[];if(Object.prototype.toString.call(separator)!=="[object RegExp]"){return string_split.apply(this,arguments)}var output=[],flags=(separator.ignoreCase?"i":"")+(separator.multiline?"m":"")+(separator.extended?"x":"")+(separator.sticky?"y":""),lastLastIndex=0,separator=new RegExp(separator.source,flags+"g"),separator2,match,lastIndex,lastLength;string+="";if(!compliantExecNpcg){separator2=new RegExp("^"+separator.source+"$(?!\\s)",flags)}limit=limit===void 0?-1>>>0:limit>>>0;while(match=separator.exec(string)){lastIndex=match.index+match[0].length;if(lastIndex>lastLastIndex){output.push(string.slice(lastLastIndex,match.index));if(!compliantExecNpcg&&match.length>1){match[0].replace(separator2,function(){for(var i=1;i<arguments.length-2;i++){if(arguments[i]===void 0){match[i]=void 0}}})}if(match.length>1&&match.index<string.length){Array.prototype.push.apply(output,match.slice(1))}lastLength=match[0].length;lastLastIndex=lastIndex;if(output.length>=limit){break}}if(separator.lastIndex===match.index){separator.lastIndex++}}if(lastLastIndex===string.length){if(lastLength||!separator.test("")){output.push("")}}else{output.push(string.slice(lastLastIndex))}return output.length>limit?output.slice(0,limit):output}})()}else if("0".split(void 0,0).length){String.prototype.split=function(separator,limit){if(separator===void 0&&limit===0)return[];return string_split.apply(this,arguments)}}if("".substr&&"0b".substr(-1)!=="b"){var string_substr=String.prototype.substr;String.prototype.substr=function(start,length){return string_substr.call(this,start<0?(start=this.length+start)<0?0:start:start,length)}}var ws=" \n \f\r \xa0\u1680\u180e\u2000\u2001\u2002\u2003"+"\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028"+"\u2029\ufeff";if(!String.prototype.trim||ws.trim()){ws="["+ws+"]";var trimBeginRegexp=new RegExp("^"+ws+ws+"*"),trimEndRegexp=new RegExp(ws+ws+"*$");String.prototype.trim=function trim(){if(this===void 0||this===null){throw new TypeError("can't convert "+this+" to object")}return String(this).replace(trimBeginRegexp,"").replace(trimEndRegexp,"")}}function toInteger(n){n=+n;if(n!==n){n=0}else if(n!==0&&n!==1/0&&n!==-(1/0)){n=(n>0||-1)*Math.floor(Math.abs(n))}return n}function isPrimitive(input){var type=typeof input;return input===null||type==="undefined"||type==="boolean"||type==="number"||type==="string"}function toPrimitive(input){var val,valueOf,toString;if(isPrimitive(input)){return input}valueOf=input.valueOf;if(typeof valueOf==="function"){val=valueOf.call(input);if(isPrimitive(val)){return val}}toString=input.toString;if(typeof toString==="function"){val=toString.call(input);if(isPrimitive(val)){return val}}throw new TypeError}var toObject=function(o){if(o==null){throw new TypeError("can't convert "+o+" to object")}return Object(o)}});
-/*
-//@ sourceMappingURL=es5-shim.map
-*/ \ No newline at end of file
diff --git a/addons/web/static/lib/es5-shim/package.json b/addons/web/static/lib/es5-shim/package.json
deleted file mode 100644
index 82c863f4b22..00000000000
--- a/addons/web/static/lib/es5-shim/package.json
+++ /dev/null
@@ -1,34 +0,0 @@
-{
- "name": "es5-shim",
- "version": "2.1.0",
- "description": "ES5 as implementable on previous engines",
- "homepage": "http://github.com/kriskowal/es5-shim/",
- "contributors": [
- "Kris Kowal <kris@cixar.com> (http://github.com/kriskowal/)",
- "Sami Samhuri <sami.samhuri@gmail.com> (http://samhuri.net/)",
- "Florian Schäfer <florian.schaefer@gmail.com> (http://github.com/fschaefer)",
- "Irakli Gozalishvili <rfobic@gmail.com> (http://jeditoolkit.com)",
- "Kit Cambridge <kitcambridge@gmail.com> (http://kitcambridge.github.com)"
- ],
- "bugs": {
- "mail": "kris@cixar.com",
- "url": "http://github.com/kriskowal/es5-shim/issues"
- },
- "licenses": [
- {
- "type": "MIT",
- "url": "http://github.com/kriskowal/es5-shim/raw/master/LICENSE"
- }
- ],
- "main": "es5-shim.js",
- "repository": {
- "type": "git",
- "url": "http://github.com/kriskowal/es5-shim.git"
- },
- "scripts": {
- "minify": "uglifyjs es5-shim.js --source-map=es5-shim.map -b ascii_only=true,beautify=false > es5-shim.min.js; uglifyjs es5-sham.js --source-map=es5-sham.map -b ascii_only=true,beautify=false > es5-sham.min.js"
- },
- "engines": {
- "node": ">=0.2.0"
- }
-}
diff --git a/addons/web/static/src/js/boot.js b/addons/web/static/src/js/boot.js
index 3dc66a02924..994b945b1d3 100644
--- a/addons/web/static/src/js/boot.js
+++ b/addons/web/static/src/js/boot.js
@@ -228,6 +228,8 @@
job_deferred.push(def);
} catch (e) {
job.error = e;
+ console.error('Error while loading ' + job.name);
+ console.error(e.stack);
}
if (!job.error) {
$.when(job_exec).then(
diff --git a/addons/web/static/src/js/chrome/action_manager.js b/addons/web/static/src/js/chrome/action_manager.js
index 0dae6b64808..18bbb6a3681 100644
--- a/addons/web/static/src/js/chrome/action_manager.js
+++ b/addons/web/static/src/js/chrome/action_manager.js
@@ -77,7 +77,7 @@ var ActionManager = Widget.extend({
/**
* Called each time the action manager is attached into the DOM.
*/
- on_attach_callback: function() {
+ on_attach_callback: function () {
this.isInDOM = true;
var currentController = this.getCurrentController();
if (currentController) {
@@ -87,7 +87,7 @@ var ActionManager = Widget.extend({
/**
* Called each time the action manager is detached from the DOM.
*/
- on_detach_callback: function() {
+ on_detach_callback: function () {
this.isInDOM = false;
var currentController = this.getCurrentController();
if (currentController) {
@@ -573,12 +573,12 @@ var ActionManager = Widget.extend({
return $.Deferred();
} else {
var w = window.open(url, '_blank');
-         if (!w || w.closed || typeof w.closed === 'undefined') {
-             var message = _t('A popup window has been blocked. You ' +
+ if (!w || w.closed || typeof w.closed === 'undefined') {
+ var message = _t('A popup window has been blocked. You ' +
'may need to change your browser settings to allow ' +
'popup windows for this page.');
this.do_warn(_t('Warning'), message, true);
-         }
+ }
}
options.on_close();
diff --git a/addons/web/static/src/js/chrome/action_manager_act_window.js b/addons/web/static/src/js/chrome/action_manager_act_window.js
index 0f6c88a1188..e66ae801bc1 100644
--- a/addons/web/static/src/js/chrome/action_manager_act_window.js
+++ b/addons/web/static/src/js/chrome/action_manager_act_window.js
@@ -22,10 +22,10 @@ ActionManager.include({
env_updated: '_onEnvUpdated',
execute_action: '_onExecuteAction',
get_controller_context: '_onGetControllerContext',
+ navigation_move: '_onNavigationMove',
update_filters: '_onUpdateFilters',
search: '_onSearch',
switch_view: '_onSwitchView',
- navigation_move: '_onNavigationMove',
}),
//--------------------------------------------------------------------------
@@ -817,6 +817,22 @@ ActionManager.include({
ev.data.callback(context || {});
},
/**
+ * Called mainly from the control panel when the focus should be given to a
+ * controller
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onNavigationMove : function (ev) {
+ switch(ev.data.direction) {
+ case 'down' :
+ var currentController = this.getCurrentController().widget;
+ currentController.giveFocus();
+ ev.stopPropagation();
+ break;
+ }
+ },
+ /**
* Handles a request to add/remove search view filters.
*
* @param {OdooEvent} ev
@@ -833,21 +849,6 @@ ActionManager.include({
data.callback(addedFilters);
},
/**
- * Called mainly from the control panel when the focus should be given to a controller
- *
- * @param {OdooEvent} event
- * @private
- */
- _onNavigationMove : function(event) {
- switch(event.data.direction) {
- case 'down' :
- var currentController = this.getCurrentController().widget;
- currentController.giveFocus();
- event.stopPropagation();
- break;
- }
- },
- /**
* Called when there is a change in the search view, so the current action's
* environment needs to be updated with the new domain, context and groupby.
*
diff --git a/addons/web/static/src/js/core/utils.js b/addons/web/static/src/js/core/utils.js
index f4031fccec9..5d707d951bb 100644
--- a/addons/web/static/src/js/core/utils.js
+++ b/addons/web/static/src/js/core/utils.js
@@ -427,13 +427,6 @@ var utils = {
return (strip_whitespace && node.data.trim() === '') ? undefined : node.data;
case 1:
var attrs = $(node).getAttributes();
- _.each(['domain', 'filter_domain', 'context', 'default_get'], function (key) {
- if (attrs[key]) {
- try {
- attrs[key] = JSON.parse(attrs[key]);
- } catch(e) { }
- }
- });
return {
tag: node.tagName.toLowerCase(),
attrs: attrs,
diff --git a/addons/web/static/src/js/views/abstract_controller.js b/addons/web/static/src/js/views/abstract_controller.js
index 4bcb21f7a4d..d2d22a626d1 100644
--- a/addons/web/static/src/js/views/abstract_controller.js
+++ b/addons/web/static/src/js/views/abstract_controller.js
@@ -12,12 +12,12 @@ odoo.define('web.AbstractController', function (require) {
* reading localstorage, ...) has to go through the controller.
*/
+var AbstractAction = require('web.AbstractAction');
var ajax = require('web.ajax');
var concurrency = require('web.concurrency');
var config = require('web.config');
var ControlPanelMixin = require('web.ControlPanelMixin');
var core = require('web.core');
-var AbstractAction = require('web.AbstractAction');
var QWeb = core.qweb;
@@ -124,12 +124,6 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
this.renderer.on_detach_callback();
},
- /**
- * Gives the focus to the renderer
- */
- giveFocus:function(){
- this.renderer.giveFocus();
- },
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
@@ -181,6 +175,12 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
return this.displayName;
},
/**
+ * Gives the focus to the renderer
+ */
+ giveFocus: function() {
+ this.renderer.giveFocus();
+ },
+ /**
* The use of this method is discouraged. It is still snakecased, because
* it currently is used in many templates, but we will move to a simpler
* mechanism as soon as we can.
@@ -283,23 +283,23 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
state: state || {},
});
},
- /**
- * Renders the html provided by the route specified by the
- * bannerRoute attribute on the controller (banner_route in the template).
- * Renders it before the view output and add a css class 'o_has_banner' to it.
- * There can be only one banner displayed at a time.
- *
- * If the banner contains stylesheet links or js files, they are moved to <head>
- * (and will only be fetched once).
- *
- * Route example:
- * @http.route('/module/hello', auth='user', type='json')
- * def hello(self):
- * return {'html': '<h1>hello, world</h1>'}
- *
- * @private
- * @returns {Deferred}
- */
+ /**
+ * Renders the html provided by the route specified by the
+ * bannerRoute attribute on the controller (banner_route in the template).
+ * Renders it before the view output and add a css class 'o_has_banner' to it.
+ * There can be only one banner displayed at a time.
+ *
+ * If the banner contains stylesheet links or js files, they are moved to <head>
+ * (and will only be fetched once).
+ *
+ * Route example:
+ * @http.route('/module/hello', auth='user', type='json')
+ * def hello(self):
+ * return {'html': '<h1>hello, world</h1>'}
+ *
+ * @private
+ * @returns {Deferred}
+ */
_renderBanner: function () {
if (this.bannerRoute !== undefined) {
var self = this;
@@ -441,30 +441,6 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
//--------------------------------------------------------------------------
/**
- * When an Odoo event arrives requesting a record to be opened, this method
- * gets the res_id, and request a switch view in the appropriate mode
- *
- * Note: this method seems wrong, it relies on the model being a basic model,
- * to get the res_id. It should receive the res_id in the event data
- * @todo move this to basic controller?
- *
- * @private
- * @param {OdooEvent} event
- * @param {number} event.data.id The local model ID for the record to be
- * opened
- * @param {string} [event.data.mode='readonly']
- */
- _onOpenRecord: function (event) {
- event.stopPropagation();
- var record = this.model.get(event.data.id, {raw: true});
- this.trigger_up('switch_view', {
- view_type: 'form',
- res_id: record.res_id,
- mode: event.data.mode || 'readonly',
- model: this.modelName,
- });
- },
- /**
* When a user clicks on an <a> link with type="action", we need to actually
* do the action. This kind of links is used a lot in no-content helpers.
*
@@ -475,10 +451,10 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
* reloaded after the dialog has been closed.
*
* @private
- * @param {OdooEvent} event
+ * @param {OdooEvent} ev
*/
- _onActionClicked: function (event) {
- var $target = $(event.currentTarget);
+ _onActionClicked: function (ev) {
+ var $target = $(ev.currentTarget);
var self = this;
var model = $target.data('model');
var method = $target.data('method');
@@ -503,13 +479,37 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
}
},
/**
+ * When an Odoo event arrives requesting a record to be opened, this method
+ * gets the res_id, and request a switch view in the appropriate mode
+ *
+ * Note: this method seems wrong, it relies on the model being a basic model,
+ * to get the res_id. It should receive the res_id in the event data
+ * @todo move this to basic controller?
+ *
+ * @private
+ * @param {OdooEvent} ev
+ * @param {number} ev.data.id The local model ID for the record to be
+ * opened
+ * @param {string} [ev.data.mode='readonly']
+ */
+ _onOpenRecord: function (ev) {
+ ev.stopPropagation();
+ var record = this.model.get(ev.data.id, {raw: true});
+ this.trigger_up('switch_view', {
+ view_type: 'form',
+ res_id: record.res_id,
+ mode: ev.data.mode || 'readonly',
+ model: this.modelName,
+ });
+ },
+ /**
* Intercepts the 'switch_view' event to add the controllerID into the data,
* and lets the event bubble up.
*
- * @param {OdooEvent} event
+ * @param {OdooEvent} ev
*/
- _onSwitchView: function (event) {
- event.data.controllerID = this.controllerID;
+ _onSwitchView: function (ev) {
+ ev.data.controllerID = this.controllerID;
},
});
diff --git a/addons/web/static/src/js/views/abstract_view.js b/addons/web/static/src/js/views/abstract_view.js
index 7fca3b83ff3..8f9f167c69f 100644
--- a/addons/web/static/src/js/views/abstract_view.js
+++ b/addons/web/static/src/js/views/abstract_view.js
@@ -313,55 +313,3 @@ var AbstractView = Class.extend({
return AbstractView;
});
-
-odoo.define('web.viewUtils', function () {
-"use strict";
-
-/**
- * FIXME: move this module to its own file in master
- */
-
-var utils = {
- /**
- * Returns the value of a group dataPoint, i.e. the value of the groupBy
- * field for the records in that group.
- *
- * @param {Object} group dataPoint of type list, corresponding to a group
- * @param {string} groupByField the name of the groupBy field
- * @returns {string | integer | false}
- */
- getGroupValue: function (group, groupByField) {
- var groupedByField = group.fields[groupByField];
- switch (groupedByField.type) {
- case 'many2one':
- return group.res_id || false;
- case 'selection':
- var descriptor = _.findWhere(groupedByField.selection, group.value);
- return descriptor && descriptor[0];
- default:
- return group.value;
- }
- },
- /**
- * States whether or not the quick create feature is available for the given
- * datapoint, depending on its groupBy field.
- *
- * @param {Object} list dataPoint of type list
- * @returns {Boolean} true iff the kanban quick create feature is available
- */
- isQuickCreateEnabled: function (list) {
- var groupByField = list.groupedBy[0] && list.groupedBy[0].split(':')[0];
- if (!groupByField) {
- return false;
- }
- var availableTypes = ['char', 'boolean', 'many2one', 'selection'];
- if (!_.contains(availableTypes, list.fields[groupByField].type)) {
- return false;
- }
- return true;
- },
-};
-
-return utils;
-
-});
diff --git a/addons/web/static/src/js/views/basic/basic_controller.js b/addons/web/static/src/js/views/basic/basic_controller.js
index 76adef0a154..183885b9fb8 100644
--- a/addons/web/static/src/js/views/basic/basic_controller.js
+++ b/addons/web/static/src/js/views/basic/basic_controller.js
@@ -556,16 +556,16 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
*
* @todo: rename db_id into handle
*
- * @param {OdooEvent} event
- * @param {Object} event.data
- * @param {string} [event.data.db_id] handle of the data to reload and
+ * @param {OdooEvent} ev
+ * @param {Object} ev.data
+ * @param {string} [ev.data.db_id] handle of the data to reload and
* re-render (reload the whole form by default)
- * @param {string[]} [event.data.fieldNames] list of the record's fields to
+ * @param {string[]} [ev.data.fieldNames] list of the record's fields to
* reload
*/
- _onReload: function (event) {
- event.stopPropagation(); // prevent other controllers from handling this request
- var data = event && event.data || {};
+ _onReload: function (ev) {
+ ev.stopPropagation(); // prevent other controllers from handling this request
+ var data = ev && ev.data || {};
var handle = data.db_id;
if (handle) {
// reload the relational field given its db_id
@@ -591,28 +591,28 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
* performed through the sidebar.
*
* @private
- * @param {OdooEvent} event
+ * @param {OdooEvent} ev
*/
- _onSidebarDataAsked: function (event) {
+ _onSidebarDataAsked: function (ev) {
var sidebarEnv = this._getSidebarEnv();
- event.data.callback(sidebarEnv);
+ ev.data.callback(sidebarEnv);
},
/**
* open the translation view for the current field
*
* @private
- * @param {OdooEvent} event
+ * @param {OdooEvent} ev
*/
- _onTranslate: function (event) {
- event.stopPropagation();
+ _onTranslate: function (ev) {
+ ev.stopPropagation();
var self = this;
- var record = this.model.get(event.data.id, {raw: true});
+ var record = this.model.get(ev.data.id, {raw: true});
this._rpc({
route: '/web/dataset/call_button',
params: {
model: 'ir.translation',
method: 'translate_fields',
- args: [record.model, record.res_id, event.data.fieldName, record.getContext()],
+ args: [record.model, record.res_id, ev.data.fieldName, record.getContext()],
}
}).then(function (result) {
self.do_action(result, {
diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js
index f87106ec0c2..d0f15aeb369 100644
--- a/addons/web/static/src/js/views/basic/basic_model.js
+++ b/addons/web/static/src/js/views/basic/basic_model.js
@@ -202,6 +202,27 @@ var BasicModel = AbstractModel.extend({
});
},
/**
+ * Completes the fields and fieldsInfo of a dataPoint with the given ones.
+ * It is useful for the cases where a record element is shared between
+ * various views, such as a one2many with a tree and a form view.
+ *
+ * @param {string} recordID a valid element ID
+ * @param {Object} viewInfo
+ * @param {Object} viewInfo.fields
+ * @param {Object} viewInfo.fieldsInfo
+ */
+ addFieldsInfo: function (recordID, viewInfo) {
+ var record = this.localData[recordID];
+ record.fields = _.extend({}, record.fields, viewInfo.fields);
+ // complete the given fieldsInfo with the fields of the main view, so
+ // that those field will be reloaded if a reload is triggered by the
+ // secondary view
+ var fieldsInfo = _.mapObject(viewInfo.fieldsInfo, function (fieldsInfo) {
+ return _.defaults({}, fieldsInfo, record.fieldsInfo[record.viewType]);
+ });
+ record.fieldsInfo = _.extend({}, record.fieldsInfo, fieldsInfo);
+ },
+ /**
* Add and process default values for a given record. Those values are
* parsed and stored in the '_changes' key of the record. For relational
* fields, sub-dataPoints are created, and missing relational data is
@@ -309,108 +330,52 @@ var BasicModel = AbstractModel.extend({
});
},
/**
- * Compute the default value that the handle field should take.
- * We need to compute this in order for new lines to be added at the correct position.
+ * Returns true if a record can be abandoned.
*
- * @private
- * @param {Object} listID
- * @param {string} position
- * @return {Object} empty object if no overrie has to be done, or:
- * field: the name of the field to override,
- * value: the value to use for that field
+ * Case for not abandoning the record:
+ *
+ * 1. flagged as 'no abandon' (i.e. during a `default_get`, including any
+ * `onchange` from a `default_get`)
+ * 2. registered in a list on addition
+ *
+ * 2.1. registered as non-new addition
+ * 2.2. registered as new additon on update
+ *
+ * 3. record is not new
+ *
+ * Otherwise, the record can be abandoned.
+ *
+ * This is useful when discarding changes on this record, as it means that
+ * we must keep the record even if some fields are invalids (e.g. required
+ * field is empty).
+ *
+ * @param {string} id id for a local resource
+ * @returns {boolean}
*/
- _computeOverrideDefaultFields: function (listID, position) {
- var list = this.localData[listID];
- var handleField;
-
- // Here listID is actually just parentID, it's not yet confirmed
- // to be a list.
- // If we are not in the case that interests us,
- // listID will be undefined and this check will work.
- if (!list) {
- return {};
- }
-
- position = position || 'bottom';
-
- // Let's find if there is a field with handle.
- if (!list.fieldsInfo) {
- return {};
+ canBeAbandoned: function (id) {
+ // 1. no drop if flagged
+ if (this.localData[id]._noAbandon) {
+ return false;
}
- for (var field in list.fieldsInfo.list) {
- if (list.fieldsInfo.list[field].widget === 'handle') {
- handleField = field;
- break;
- // If there are 2 handle fields on the same list,
- // we take the first one we find.
- // And that will be alphabetically on the field name...
+ // 2. no drop in a list on "ADD in some cases
+ var record = this.localData[id];
+ var parent = this.localData[record.parentID];
+ if (parent) {
+ var entry = _.findWhere(parent._savePoint, {operation: 'ADD', id: id});
+ if (entry) {
+ // 2.1. no drop on non-new addition in list
+ if (!entry.isNew) {
+ return false;
+ }
+ // 2.2. no drop on new addition on "UPDATE"
+ var lastEntry = _.last(parent._savePoint);
+ if (lastEntry.operation === 'UPDATE' && lastEntry.id === id) {
+ return false;
+ }
}
}
-
- if (!handleField) {
- return {};
- }
-
- // We don't want to override the default value
- // if the list is not ordered by the handle field.
- var isOrderedByHandle = list.orderedBy
- && list.orderedBy.length
- && list.orderedBy[0].asc === true
- && list.orderedBy[0].name === handleField;
-
- if (!isOrderedByHandle) {
- return {};
- }
-
- // We compute the list (get) to apply the pending changes before doing our work,
- // otherwise new lines might not be taken into account.
- // We use raw: true because we only need to load the first level of relation.
- var computedList = this.get(list.id, {raw: true});
-
- // We don't need to worry about the position of a new line if the list is empty.
- if (!computedList || !computedList.data || !computedList.data.length) {
- return {};
- }
-
- // If there are less elements in the list than the limit of
- // the page then take the index of the last existing line.
-
- // If the button is at the top, we want the new element on
- // the first line of the page.
-
- // If the button is at the bottom, we want the new element
- // after the last line of the page
- // (= theorically it will be the first element of the next page).
-
- // We ignore list.offset because computedList.data
- // will only have the current page elements.
-
- var index = Math.min(
- computedList.data.length - 1,
- position !== 'top' ? list.limit - 1 : 0
- );
-
- // This positioning will almost be correct. There might just be
- // an issue if several other lines have the same handleFieldValue.
-
- // TODO ideally: if there is an element with the same handleFieldValue,
- // that one and all the following elements must be incremented
- // by 1 (at least until there is a gap in the numbering).
-
- // We don't do it now because it's not an important case.
- // However, we can for sure increment by 1 if we are on the last page.
-
- var handleFieldValue = computedList.data[index].data[handleField];
- if (position === 'top') {
- handleFieldValue--;
- } else if (list.count <= list.offset + list.limit - (list.tempLimitIncrement || 0)) {
- handleFieldValue++;
- }
-
- return {
- field: handleField,
- value: handleFieldValue,
- };
+ // 3. drop new records
+ return this.isNew(id);
},
/**
* Delete a list of records, then, if the records have a parent, reload it.
@@ -518,6 +483,20 @@ var BasicModel = AbstractModel.extend({
});
},
/**
+ * For list resources, this freezes the current records order.
+ *
+ * @param {string} listID a valid element ID of type list
+ */
+ freezeOrder: function (listID) {
+ var list = this.localData[listID];
+ if (list.type === 'record') {
+ return;
+ }
+ list = this._applyX2ManyOperations(list);
+ this._sortList(list);
+ this.localData[listID].orderedResIDs = list.res_ids;
+ },
+ /**
* The get method first argument is the handle returned by the load method.
* It is optional (the handle can be undefined). In some case, it makes
* sense to use the handle as a key, for example the BasicModel holds the
@@ -692,54 +671,6 @@ var BasicModel = AbstractModel.extend({
return _t("New");
},
/**
- * Returns true if a record can be abandoned.
- *
- * Case for not abandoning the record:
- *
- * 1. flagged as 'no abandon' (i.e. during a `default_get`, including any
- * `onchange` from a `default_get`)
- * 2. registered in a list on addition
- *
- * 2.1. registered as non-new addition
- * 2.2. registered as new additon on update
- *
- * 3. record is not new
- *
- * Otherwise, the record can be abandoned.
- *
- * This is useful when discarding changes on this record, as it means that
- * we must keep the record even if some fields are invalids (e.g. required
- * field is empty).
- *
- * @param {string} id id for a local resource
- * @returns {boolean}
- */
- canBeAbandoned: function (id) {
- // 1. no drop if flagged
- if (this.localData[id]._noAbandon) {
- return false;
- }
- // 2. no drop in a list on "ADD in some cases
- var record = this.localData[id];
- var parent = this.localData[record.parentID];
- if (parent) {
- var entry = _.findWhere(parent._savePoint, {operation: 'ADD', id: id});
- if (entry) {
- // 2.1. no drop on non-new addition in list
- if (!entry.isNew) {
- return false;
- }
- // 2.2. no drop on new addition on "UPDATE"
- var lastEntry = _.last(parent._savePoint);
- if (lastEntry.operation === 'UPDATE' && lastEntry.id === id) {
- return false;
- }
- }
- }
- // 3. drop new records
- return this.isNew(id);
- },
- /**
* Returns true if a record is dirty. A record is considered dirty if it has
* some unsaved changes, marked by the _isDirty property on the record or
* one of its subrecords.
@@ -1176,41 +1107,6 @@ var BasicModel = AbstractModel.extend({
});
},
/**
- * Completes the fields and fieldsInfo of a dataPoint with the given ones.
- * It is useful for the cases where a record element is shared between
- * various views, such as a one2many with a tree and a form view.
- *
- * @param {string} recordID a valid element ID
- * @param {Object} viewInfo
- * @param {Object} viewInfo.fields
- * @param {Object} viewInfo.fieldsInfo
- */
- addFieldsInfo: function (recordID, viewInfo) {
- var record = this.localData[recordID];
- record.fields = _.extend({}, record.fields, viewInfo.fields);
- // complete the given fieldsInfo with the fields of the main view, so
- // that those field will be reloaded if a reload is triggered by the
- // secondary view
- var fieldsInfo = _.mapObject(viewInfo.fieldsInfo, function (fieldsInfo) {
- return _.defaults({}, fieldsInfo, record.fieldsInfo[record.viewType]);
- });
- record.fieldsInfo = _.extend({}, record.fieldsInfo, fieldsInfo);
- },
- /**
- * For list resources, this freezes the current records order.
- *
- * @param {string} listID a valid element ID of type list
- */
- freezeOrder: function (listID) {
- var list = this.localData[listID];
- if (list.type === 'record') {
- return;
- }
- list = this._applyX2ManyOperations(list);
- this._sortList(list);
- this.localData[listID].orderedResIDs = list.res_ids;
- },
- /**
* Manually sets a resource as dirty. This is used to notify that a field
* has been modified, but with an invalid value. In that case, the value is
* not sent to the basic model, but the record should still be flagged as
@@ -2062,6 +1958,110 @@ var BasicModel = AbstractModel.extend({
return hasOnchange ? specs : false;
},
/**
+ * Compute the default value that the handle field should take.
+ * We need to compute this in order for new lines to be added at the correct position.
+ *
+ * @private
+ * @param {Object} listID
+ * @param {string} position
+ * @return {Object} empty object if no overrie has to be done, or:
+ * field: the name of the field to override,
+ * value: the value to use for that field
+ */
+ _computeOverrideDefaultFields: function (listID, position) {
+ var list = this.localData[listID];
+ var handleField;
+
+ // Here listID is actually just parentID, it's not yet confirmed
+ // to be a list.
+ // If we are not in the case that interests us,
+ // listID will be undefined and this check will work.
+ if (!list) {
+ return {};
+ }
+
+ position = position || 'bottom';
+
+ // Let's find if there is a field with handle.
+ if (!list.fieldsInfo) {
+ return {};
+ }
+ for (var field in list.fieldsInfo.list) {
+ if (list.fieldsInfo.list[field].widget === 'handle') {
+ handleField = field;
+ break;
+ // If there are 2 handle fields on the same list,
+ // we take the first one we find.
+ // And that will be alphabetically on the field name...
+ }
+ }
+
+ if (!handleField) {
+ return {};
+ }
+
+ // We don't want to override the default value
+ // if the list is not ordered by the handle field.
+ var isOrderedByHandle = list.orderedBy
+ && list.orderedBy.length
+ && list.orderedBy[0].asc === true
+ && list.orderedBy[0].name === handleField;
+
+ if (!isOrderedByHandle) {
+ return {};
+ }
+
+ // We compute the list (get) to apply the pending changes before doing our work,
+ // otherwise new lines might not be taken into account.
+ // We use raw: true because we only need to load the first level of relation.
+ var computedList = this.get(list.id, {raw: true});
+
+ // We don't need to worry about the position of a new line if the list is empty.
+ if (!computedList || !computedList.data || !computedList.data.length) {
+ return {};
+ }
+
+ // If there are less elements in the list than the limit of
+ // the page then take the index of the last existing line.
+
+ // If the button is at the top, we want the new element on
+ // the first line of the page.
+
+ // If the button is at the bottom, we want the new element
+ // after the last line of the page
+ // (= theorically it will be the first element of the next page).
+
+ // We ignore list.offset because computedList.data
+ // will only have the current page elements.
+
+ var index = Math.min(
+ computedList.data.length - 1,
+ position !== 'top' ? list.limit - 1 : 0
+ );
+
+ // This positioning will almost be correct. There might just be
+ // an issue if several other lines have the same handleFieldValue.
+
+ // TODO ideally: if there is an element with the same handleFieldValue,
+ // that one and all the following elements must be incremented
+ // by 1 (at least until there is a gap in the numbering).
+
+ // We don't do it now because it's not an important case.
+ // However, we can for sure increment by 1 if we are on the last page.
+
+ var handleFieldValue = computedList.data[index].data[handleField];
+ if (position === 'top') {
+ handleFieldValue--;
+ } else if (list.count <= list.offset + list.limit - (list.tempLimitIncrement || 0)) {
+ handleFieldValue++;
+ }
+
+ return {
+ field: handleField,
+ value: handleFieldValue,
+ };
+ },
+ /**
* Evaluate modifiers
*
* @private
@@ -2258,6 +2258,46 @@ var BasicModel = AbstractModel.extend({
return $.when(def);
},
/**
+ * Fetches data for reference fields and assigns these data to newly
+ * created datapoint.
+ * Then places datapoint reference into parent record.
+ *
+ * @param {Object} datapoints a collection of ids classed by model,
+ * @see _getDataToFetchByModel
+ * @param {string} model
+ * @param {string} fieldName
+ * @returns {Deferred}
+ */
+ _fetchReferenceData: function (datapoints, model, fieldName) {
+ var self = this;
+ var ids = _.map(Object.keys(datapoints), function (id) { return parseInt(id); });
+ // we need one parent for the context (they all have the same)
+ var parent = datapoints[ids[0]][0];
+ var def = self._rpc({
+ model: model,
+ method: 'name_get',
+ args: [ids],
+ context: self.localData[parent].getContext({fieldName: fieldName}),
+ }).then(function (result) {
+ _.each(result, function (el) {
+ var parentIDs = datapoints[el[0]];
+ _.each(parentIDs, function (parentID) {
+ var parent = self.localData[parentID];
+ var referenceDp = self._makeDataPoint({
+ data: {
+ id: el[0],
+ display_name: el[1],
+ },
+ modelName: model,
+ parentID: parent,
+ });
+ parent.data[fieldName] = referenceDp.id;
+ });
+ });
+ });
+ return def;
+ },
+ /**
* Fetch the extra data (`name_get`) for the reference fields of the record
* model.
*
@@ -2294,59 +2334,13 @@ var BasicModel = AbstractModel.extend({
_fetchReferenceBatched: function (list, fieldName) {
var self = this;
list = this._applyX2ManyOperations(list);
+ this._sortList(list);
- // collect ids by model
- var toFetch = {};
- _.each(list.data, function (dataPoint) {
- var record = self.localData[dataPoint];
- var value = record.data[fieldName];
- // if the reference field has already been fetched, the value is a
- // datapoint ID, and in this case there's nothing to do
- if (value && !self.localData[value]) {
- var model = value.split(',')[0];
- var resID = value.split(',')[1];
- if (!(model in toFetch)) {
- toFetch[model] = {};
- }
- // there could be multiple datapoints with the same model/resID
- if (toFetch[model][resID]) {
- toFetch[model][resID].push(dataPoint);
- } else {
- toFetch[model][resID] = [dataPoint];
- }
- }
- });
-
+ var toFetch = this._getDataToFetchByModel(list, fieldName);
var defs = [];
- var def;
// one name_get by model
_.each(toFetch, function (datapoints, model) {
- var ids = _.map(Object.keys(datapoints), function (id) { return parseInt(id); });
- // we need one parent for the context (they all have the same)
- var parent = datapoints[ids[0]][0];
- def = self._rpc({
- model: model,
- method: 'name_get',
- args: [ids],
- context: self.localData[parent].getContext({fieldName: fieldName}),
- }).then(function (result) {
- _.each(result, function (el) {
- var parentIDs = datapoints[el[0]];
- _.each(parentIDs, function (parentID) {
- var parent = self.localData[parentID];
- var referenceDp = self._makeDataPoint({
- data: {
- id: el[0],
- display_name: el[1],
- },
- modelName: model,
- parentID: parent,
- });
- parent.data[fieldName] = referenceDp.id;
- });
- });
- });
- defs.push(def);
+ defs.push(self._fetchReferenceData(datapoints, model, fieldName));
});
return $.when.apply($, defs);
@@ -2369,6 +2363,94 @@ var BasicModel = AbstractModel.extend({
return $.when.apply($, defs);
},
/**
+ * Batch reference requests for all records in list.
+ *
+ * @see _fetchReferencesSingleBatch
+ * @param {Object} list a valid resource object
+ * @param {string} fieldName
+ * @returns {Deferred}
+ */
+ _fetchReferenceSingleBatch: function (list, fieldName) {
+ var self = this;
+
+ // collect ids by model
+ var toFetch = {};
+ _.each(list.data, function (groupIndex) {
+ var group = self.localData[groupIndex];
+ _.extend(toFetch, self._getDataToFetchByModel(group, fieldName));
+ });
+
+ var defs = [];
+ // one name_get by model
+ _.each(toFetch, function (datapoints, model) {
+ defs.push(self._fetchReferenceData(datapoints, model, fieldName));
+ });
+
+ return $.when.apply($, defs);
+ },
+ /**
+ * Batch requests for all reference field in list's children.
+ * Called by _readGroup to make only one 'name_get' rpc by fieldName.
+ *
+ * @param {Object} list a valid resource object
+ * @returns {Deferred}
+ */
+ _fetchReferencesSingleBatch: function (list) {
+ var defs = [];
+ var fieldNames = list.getFieldNames();
+ for (var fIndex in fieldNames) {
+ var field = list.fields[fieldNames[fIndex]];
+ if (field.type === 'reference') {
+ defs.push(this._fetchReferenceSingleBatch(list, fieldNames[fIndex]));
+ }
+ }
+ return $.when.apply($, defs);
+ },
+ /**
+ * Fetch model data from server, relationally to fieldName and resulted
+ * field relation. For example, if fieldName is "tag_ids" and referred to
+ * project.tags, it will fetch project.tags' related fields where its id is
+ * contained in toFetch.ids array.
+ *
+ * @param {Object} list a valid resource object
+ * @param {Object} toFetch a list of records and res_ids,
+ * @see _getDataToFetch
+ * @param {string} fieldName
+ * @returns {Deferred}
+ */
+ _fetchRelatedData: function (list, toFetch, fieldName) {
+ var self = this;
+ var ids = _.keys(toFetch);
+ for (var i = 0; i < ids.length; i++) {
+ ids[i] = Number(ids[i]);
+ }
+ var fieldInfo = list.fieldsInfo[list.viewType][fieldName];
+
+ if (!ids.length || fieldInfo.__no_fetch) {
+ return $.when();
+ }
+
+ var def;
+ var fieldNames = _.keys(fieldInfo.relatedFields);
+ if (fieldNames.length) {
+ var field = list.fields[fieldName];
+ def = this._rpc({
+ model: field.relation,
+ method: 'read',
+ args: [ids, fieldNames],
+ context: list.getContext() || {},
+ });
+ } else {
+ def = $.when(_.map(ids, function (id) {
+ return {id:id};
+ }));
+ }
+ return def.then(function (result) {
+ var records = _.uniq(_.flatten(_.values(toFetch)));
+ self._updateRecordsData(records, fieldName, result);
+ });
+ },
+ /**
* This method is incorrectly named. It should be named something like
* _fetchMany2OneData.
*
@@ -2658,9 +2740,14 @@ var BasicModel = AbstractModel.extend({
* Fetch all data in a ungrouped list
*
* @param {Object} list a valid resource object
- * @returns {Deferred<Object>} resolves to the fecthed list
+ * @param {Object} [options]
+ * @param {boolean} [options.enableRelationalFetch=true] if false, will not
+ * fetch x2m and relational data (that will be done by _readGroup in this
+ * case).
+ * @returns {Deferred<Object>} resolves to the fetched list
*/
- _fetchUngroupedList: function (list) {
+ _fetchUngroupedList: function (list, options) {
+ options = _.defaults(options || {}, {enableRelationalFetch: true});
var self = this;
var def;
if (list.static) {
@@ -2678,14 +2765,32 @@ var BasicModel = AbstractModel.extend({
def = this._searchReadUngroupedList(list);
}
return def.then(function () {
- return $.when(
- self._fetchX2ManysBatched(list),
- self._fetchReferencesBatched(list));
+ if (options.enableRelationalFetch) {
+ return $.when(
+ self._fetchX2ManysBatched(list, options),
+ self._fetchReferencesBatched(list, options)
+ );
+ }
}).then(function () {
return list;
});
},
/**
+ * batch requests for 1 x2m in list
+ *
+ * @see _fetchX2ManysBatched
+ * @param {Object} list
+ * @param {string} fieldName
+ * @returns {Deferred}
+ */
+ _fetchX2ManyBatched: function (list, fieldName) {
+ list = this._applyX2ManyOperations(list);
+ this._sortList(list);
+
+ var toFetch = this._getDataToFetch(list, fieldName);
+ return this._fetchRelatedData(list, toFetch, fieldName);
+ },
+ /**
* X2Manys have to be fetched by separate rpcs (their data are stored on
* different models). This method takes a record, look at its x2many fields,
* then, if necessary, create a local resource and fetch the corresponding
@@ -2753,105 +2858,62 @@ var BasicModel = AbstractModel.extend({
return $.when.apply($, defs);
},
/**
- * batch requests for 1 x2m in list
+ * batch request for x2ms for datapoint of type list
*
- * @see _fetchX2ManysBatched
* @param {Object} list
- * @param {string} fieldName
* @returns {Deferred}
*/
- _fetchX2ManyBatched: function (list, fieldName) {
- var self = this;
- var field = list.fields[fieldName];
- var fieldInfo = list.fieldsInfo[list.viewType][fieldName];
- var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode];
- var fieldsInfo = view ? view.fieldsInfo : fieldInfo.fieldsInfo;
- var fields = view ? view.fields : fieldInfo.relatedFields;
- var viewType = view ? view.type : fieldInfo.viewType;
- list = this._applyX2ManyOperations(list);
- this._sortList(list);
- var x2mRecords = [];
-
- // step 1: collect ids
- var ids = [];
- _.each(list.data, function (dataPoint) {
- var record = self.localData[dataPoint];
- if (typeof record.data[fieldName] === 'string') {
- // in this case, the value is a local ID, which means that the
- // record has already been processed. It can happen for example
- // when a user adds a record in a m2m relation, or loads more
- // records in a kanban column
- return;
+ _fetchX2ManysBatched: function (list) {
+ var defs = [];
+ var fieldNames = list.getFieldNames();
+ for (var i = 0; i < fieldNames.length; i++) {
+ var field = list.fields[fieldNames[i]];
+ if (field.type === 'many2many' || field.type === 'one2many') {
+ defs.push(this._fetchX2ManyBatched(list, fieldNames[i]));
}
- x2mRecords.push(record);
- ids = _.unique(ids.concat(record.data[fieldName] || []));
- var m2mList = self._makeDataPoint({
- fieldsInfo: fieldsInfo,
- fields: fields,
- modelName: field.relation,
- parentID: record.id,
- res_ids: record.data[fieldName],
- static: true,
- type: 'list',
- viewType: viewType,
- });
- record.data[fieldName] = m2mList.id;
- });
-
- if (!ids.length || fieldInfo.__no_fetch) {
- return $.when();
}
- var def;
- var fieldNames = _.keys(fieldInfo.relatedFields);
- // step 2: fetch data from server
- // if we want specific fields
- // if not we return an array of objects with the id
- // to avoid fetching all the relation fields and an useless rpc
- if (fieldNames.length) {
- def = this._rpc({
- model: field.relation,
- method: 'read',
- args: [ids, fieldNames],
- context: list.getContext() || {},
- });
- } else {
- def = $.when(_.map(ids, function (id) {
- return {id:id};
- }));
- }
- return def.then(function (results) {
- // step 3: assign values to correct datapoints
- _.each(x2mRecords, function (record) {
- var m2mList = self.localData[record.data[fieldName]];
- m2mList.data = [];
- _.each(m2mList.res_ids, function (res_id) {
- var dataPoint = self._makeDataPoint({
- modelName: field.relation,
- data: _.findWhere(results, {id: res_id}),
- fields: fields,
- fieldsInfo: fieldsInfo,
- parentID: m2mList.id,
- viewType: viewType,
- });
- m2mList.data.push(dataPoint.id);
- m2mList._cache[res_id] = dataPoint.id;
- });
+ return $.when.apply($, defs);
+ },
+ /**
+ * For a non-static list, batches requests for all its sublists' records.
+ * Make only one rpc for all records on the concerned field.
+ *
+ * @see _fetchX2ManysSingleBatch
+ * @param {Object} list a valid resource object, its data must be another
+ * list containing records
+ * @param {string} fieldName
+ * @returns {Deferred}
+ */
+ _fetchX2ManySingleBatch: function (list, fieldName) {
+ var self = this;
+ var toFetch = {};
+ _.each(list.data, function (groupIndex) {
+ var group = self.localData[groupIndex];
+ var nextDataToFetch = self._getDataToFetch(group, fieldName);
+ _.each(_.keys(nextDataToFetch), function (id) {
+ if (toFetch[id]) {
+ toFetch[id] = toFetch[id].concat(nextDataToFetch[id]);
+ } else {
+ toFetch[id] = nextDataToFetch[id];
+ }
});
});
+ return self._fetchRelatedData(list, toFetch, fieldName);
},
/**
- * batch request for x2ms for datapoint of type list
+ * Batch requests for all x2m in list's children.
+ * Called by _readGroup to make only one 'read' rpc by fieldName.
*
- * @param {Object} list
+ * @param {Object} list a valid resource object
* @returns {Deferred}
*/
- _fetchX2ManysBatched: function (list) {
+ _fetchX2ManysSingleBatch: function (list) {
var defs = [];
var fieldNames = list.getFieldNames();
for (var i = 0; i < fieldNames.length; i++) {
var field = list.fields[fieldNames[i]];
- if (field.type === 'many2many' || field.type === 'one2many') {
- defs.push(this._fetchX2ManyBatched(list, fieldNames[i]));
+ if (field.type === 'many2many' || field.type === 'one2many'){
+ defs.push(this._fetchX2ManySingleBatch(list, fieldNames[i]));
}
}
return $.when.apply($, defs);
@@ -3155,6 +3217,90 @@ var BasicModel = AbstractModel.extend({
return context.eval();
},
/**
+ * Collects from a record a list of ids to fetch, according to fieldName,
+ * and a list of records where to set the result of the fetch.
+ *
+ * @param {Object} list a list containing records we want to get the ids,
+ * it assumes _applyX2ManyOperations and _sort have been already called on
+ * this list
+ * @param {string} fieldName
+ * @return {Object} a list of records and res_ids
+ */
+ _getDataToFetch: function (list, fieldName) {
+ var self = this;
+ var field = list.fields[fieldName];
+ var fieldInfo = list.fieldsInfo[list.viewType][fieldName];
+ var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode];
+ var fieldsInfo = view ? view.fieldsInfo : fieldInfo.fieldsInfo;
+ var fields = view ? view.fields : fieldInfo.relatedFields;
+ var viewType = view ? view.type : fieldInfo.viewType;
+
+ var toFetch = {};
+ _.each(list.data, function (dataPoint) {
+ var record = self.localData[dataPoint];
+ if (typeof record.data[fieldName] === 'string'){
+ // in this case, the value is a local ID, which means that the
+ // record has already been processed. It can happen for example
+ // when a user adds a record in a m2m relation, or loads more
+ // records in a kanban column
+ return;
+ }
+
+ _.each(record.data[fieldName], function (id) {
+ toFetch[id] = toFetch[id] || [];
+ toFetch[id].push(record);
+ });
+
+ var m2mList = self._makeDataPoint({
+ fieldsInfo: fieldsInfo,
+ fields: fields,
+ modelName: field.relation,
+ parentID: record.id,
+ res_ids: record.data[fieldName],
+ static: true,
+ type: 'list',
+ viewType: viewType,
+ });
+ record.data[fieldName] = m2mList.id;
+ });
+
+ return toFetch;
+ },
+ /**
+ * Determines and returns from a list a collection of ids classed by
+ * their model.
+ *
+ * @param {Object} list a valid resource object
+ * @param {string} fieldName
+ * @returns {Object} each key represent a model and contain a sub-object
+ * where each key represent an id (res_id) containing an array of
+ * webclient id (referred to a datapoint, so not a res_id).
+ */
+ _getDataToFetchByModel: function (list, fieldName) {
+ var self = this;
+ var toFetch = {};
+ _.each(list.data, function (dataPoint) {
+ var record = self.localData[dataPoint];
+ var value = record.data[fieldName];
+ // if the reference field has already been fetched, the value is a
+ // datapoint ID, and in this case there's nothing to do
+ if (value && !self.localData[value]) {
+ var model = value.split(',')[0];
+ var resID = value.split(',')[1];
+ if (!(model in toFetch)) {
+ toFetch[model] = {};
+ }
+ // there could be multiple datapoints with the same model/resID
+ if (toFetch[model][resID]) {
+ toFetch[model][resID].push(dataPoint);
+ } else {
+ toFetch[model][resID] = [dataPoint];
+ }
+ }
+ });
+ return toFetch;
+ },
+ /**
* Some records are associated to a/some domain(s). This method allows to
* retrieve them, evaluated.
*
@@ -3448,7 +3594,7 @@ var BasicModel = AbstractModel.extend({
return this._readGroup(dataPoint, options);
}
if (dataPoint.type === 'list' && !dataPoint.groupedBy.length) {
- return this._fetchUngroupedList(dataPoint);
+ return this._fetchUngroupedList(dataPoint, options);
}
},
/**
@@ -4115,6 +4261,7 @@ var BasicModel = AbstractModel.extend({
list.count += newGroup.count;
if (newGroup.isOpen && newGroup.count > 0) {
openGroupCount++;
+ options = _.defaults({enableRelationalFetch: false}, options);
defs.push(self._load(newGroup, options));
}
});
@@ -4132,6 +4279,7 @@ var BasicModel = AbstractModel.extend({
emptyGroup.aggregateValues = {};
});
}
+
return $.when.apply($, defs).then(function () {
if (!options || !options.onlyGroups) {
// generate the res_ids of the main list, being the concatenation
@@ -4141,6 +4289,10 @@ var BasicModel = AbstractModel.extend({
}));
}
return list;
+ }).then(function () {
+ self._fetchX2ManysSingleBatch(list);
+ self._fetchReferencesSingleBatch(list);
+ return list;
});
});
},
@@ -4482,6 +4634,45 @@ var BasicModel = AbstractModel.extend({
}
},
/**
+ * Helper method to create datapoints and assign them values, then link
+ * those datapoints into records' data.
+ *
+ * @param {Object[]} records a list of record where datapoints will be
+ * assigned, it assumes _applyX2ManyOperations and _sort have been
+ * already called on this list
+ * @param {string} fieldName concerned field in records
+ * @param {Object[]} values typically a list of values got from a rpc
+ */
+ _updateRecordsData: function (records, fieldName, values) {
+ if (!records.length || !values) {
+ return;
+ }
+ var self = this;
+ var field = records[0].fields[fieldName];
+ var fieldInfo = records[0].fieldsInfo[records[0].viewType][fieldName];
+ var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode];
+ var fieldsInfo = view ? view.fieldsInfo : fieldInfo.fieldsInfo;
+ var fields = view ? view.fields : fieldInfo.relatedFields;
+ var viewType = view ? view.type : fieldInfo.viewType;
+
+ _.each(records, function (record) {
+ var x2mList = self.localData[record.data[fieldName]];
+ x2mList.data = [];
+ _.each(x2mList.res_ids, function (res_id) {
+ var dataPoint = self._makeDataPoint({
+ modelName: field.relation,
+ data: _.findWhere(values, {id: res_id}),
+ fields: fields,
+ fieldsInfo: fieldsInfo,
+ parentID: x2mList.id,
+ viewType: viewType,
+ });
+ x2mList.data.push(dataPoint.id);
+ x2mList._cache[res_id] = dataPoint.id;
+ });
+ });
+ },
+ /**
* Helper method. Recursively traverses the data, starting from the element
* record (or list), then following all relations. This is useful when one
* want to determine a property for the current record.
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 33267283a15..8db200183ef 100644
--- a/addons/web/static/src/js/views/basic/basic_renderer.js
+++ b/addons/web/static/src/js/views/basic/basic_renderer.js
@@ -136,31 +136,12 @@ var BasicRenderer = AbstractRenderer.extend({
dom.setSelectionRange(field.getFocusableElement().get(0), {start: offset, end: offset});
}
},
+
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
- * Add a tooltip on a $node, depending on a field description
- *
- * @param {FieldWidget} widget
- * @param {$node} $node
- */
- _addFieldTooltip: function (widget, $node) {
- // optional argument $node, the jQuery element on which the tooltip
- // should be attached if not given, the tooltip is attached on the
- // widget's $el
- $node = $node.length ? $node : widget.$el;
- $node.tooltip({
- title: function () {
- return qweb.render('WidgetLabel.tooltip', {
- debug: config.debug,
- widget: widget,
- });
- }
- });
- },
- /**
* Activates the widget at the given index for the given record if possible
* or the "next" possible one. Usually, a widget can be activated if it is
* in edit mode, and if it is visible.
@@ -239,6 +220,26 @@ var BasicRenderer = AbstractRenderer.extend({
return this._activateFieldWidget(record, currentIndex, {inc:-1});
},
/**
+ * Add a tooltip on a $node, depending on a field description
+ *
+ * @param {FieldWidget} widget
+ * @param {$node} $node
+ */
+ _addFieldTooltip: function (widget, $node) {
+ // optional argument $node, the jQuery element on which the tooltip
+ // should be attached if not given, the tooltip is attached on the
+ // widget's $el
+ $node = $node.length ? $node : widget.$el;
+ $node.tooltip({
+ title: function () {
+ return qweb.render('WidgetLabel.tooltip', {
+ debug: config.debug,
+ widget: widget,
+ });
+ }
+ });
+ },
+ /**
* Does the necessary DOM updates to match the given modifiers data. The
* modifiers data is supposed to contain the properly evaluated modifiers
* associated to the given records and elements.
@@ -685,7 +686,6 @@ var BasicRenderer = AbstractRenderer.extend({
return $el;
},
-
/**
* Rerenders a given widget and make sure the associated data which
* referenced the old one is updated.
diff --git a/addons/web/static/src/js/views/form/form_controller.js b/addons/web/static/src/js/views/form/form_controller.js
index b7c8e8a6214..9c354be6765 100644
--- a/addons/web/static/src/js/views/form/form_controller.js
+++ b/addons/web/static/src/js/views/form/form_controller.js
@@ -2,9 +2,9 @@ odoo.define('web.FormController', function (require) {
"use strict";
var BasicController = require('web.BasicController');
-var dialogs = require('web.view_dialogs');
var core = require('web.core');
var Dialog = require('web.Dialog');
+var dialogs = require('web.view_dialogs');
var Sidebar = require('web.Sidebar');
var _t = core._t;
@@ -41,6 +41,15 @@ var FormController = BasicController.extend({
this.toolbarActions = params.toolbarActions || {};
},
/**
+ * Called each time the form view is attached into the DOM
+ *
+ * @todo convert to new style
+ */
+ on_attach_callback: function () {
+ this._super.apply(this, arguments);
+ this.autofocus();
+ },
+ /**
* Force mode back to readonly. Whenever we leave a form view, it is saved,
* and should no longer be in edit mode.
*
@@ -54,7 +63,6 @@ var FormController = BasicController.extend({
// Public
//--------------------------------------------------------------------------
-
/**
* Calls autofocus on the renderer
*/
@@ -116,15 +124,6 @@ var FormController = BasicController.extend({
return this.model.getName(this.handle);
},
/**
- * Called each time the form view is attached into the DOM
- *
- * @todo convert to new style
- */
- on_attach_callback: function () {
- this._super.apply(this, arguments);
- this.autofocus();
- },
- /**
* Render buttons for the control panel. The form view can be rendered in
* a dialog, and in that case, if we have buttons defined in the footer, we
* have to use them instead of the standard buttons.
@@ -258,6 +257,7 @@ var FormController = BasicController.extend({
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
+
/**
* Assign on the buttons save and discard additionnal behavior to facilitate
* the work of the users doing input only using the keyboard
@@ -268,18 +268,18 @@ var FormController = BasicController.extend({
*/
_assignSaveCancelKeyboardBehavior: function ($saveCancelButtonContainer) {
var self = this;
- $saveCancelButtonContainer.children().on('keydown', function(e) {
+ $saveCancelButtonContainer.children().on('keydown', function (e) {
switch(e.which) {
case $.ui.keyCode.ENTER:
e.preventDefault();
- self.saveRecord.apply(self);
+ self.saveRecord();
break;
case $.ui.keyCode.ESCAPE:
e.preventDefault();
- self._discardChanges.apply(self);
+ self._discardChanges();
break;
case $.ui.keyCode.TAB:
- if (!e.shiftKey && e.target.classList.contains("btn-primary")) {
+ if (!e.shiftKey && e.target.classList.contains('btn-primary')) {
$saveCancelButtonContainer.tooltip('show');
e.preventDefault();
}
@@ -444,12 +444,12 @@ var FormController = BasicController.extend({
},
/**
* @private
- * @param {OdooEvent} event
+ * @param {OdooEvent} ev
*/
- _onButtonClicked: function (event) {
+ _onButtonClicked: function (ev) {
// stop the event's propagation as a form controller might have other
// form controllers in its descendants (e.g. in a FormViewDialog)
- event.stopPropagation();
+ ev.stopPropagation();
var self = this;
var def;
@@ -462,11 +462,11 @@ var FormController = BasicController.extend({
// we need to reget the record to make sure we have changes made
// by the basic model, such as the new res_id, if the record is
// new.
- var record = self.model.get(event.data.record.id);
+ var record = self.model.get(ev.data.record.id);
return self._callButtonAction(attrs, record);
});
}
- var attrs = event.data.attrs;
+ var attrs = ev.data.attrs;
if (attrs.confirm) {
var d = $.Deferred();
Dialog.confirm(this, attrs.confirm, {
@@ -476,7 +476,7 @@ var FormController = BasicController.extend({
});
def = d.promise();
} else if (attrs.special === 'cancel') {
- def = this._callButtonAction(attrs, event.data.record);
+ def = this._callButtonAction(attrs, ev.data.record);
} else if (!attrs.special || attrs.special === 'save') {
// save the record but don't switch to readonly mode
def = saveAndExecuteAction();
@@ -512,16 +512,16 @@ var FormController = BasicController.extend({
/**
* Destroy subdialog widgets after an action is finished.
*
- * @param {OdooEvent} event
+ * @param {OdooEvent} ev
* @private
*/
- _onDoAction: function (event) {
- var self=this;
+ _onDoAction: function (ev) {
+ var self = this;
// A priori, different widgets could write on the "on_success" key.
// Below we ensure that all the actions required by those widgets
// are executed in a suitable order before every cycle of destruction.
- var callback = event.data.on_success || function () {};
- event.data.on_success = function () {
+ var callback = ev.data.on_success || function () {};
+ ev.data.on_success = function () {
callback();
function isDialog (widget) {
return (widget instanceof Dialog);
@@ -598,11 +598,11 @@ var FormController = BasicController.extend({
* with the one of the form view.
*
* @private
- * @param {OdooEvent} event
+ * @param {OdooEvent} ev
*/
- _onOpenOne2ManyRecord: function (event) {
- event.stopPropagation();
- var data = event.data;
+ _onOpenOne2ManyRecord: function (ev) {
+ ev.stopPropagation();
+ var data = ev.data;
var record;
if (data.id) {
record = this.model.get(data.id, {raw: true});
@@ -622,29 +622,29 @@ var FormController = BasicController.extend({
res_id: record && record.res_id,
res_model: data.field.relation,
shouldSaveLocally: true,
- title: (record ? _t("Open: ") : _t("Create ")) + (event.target.string || data.field.string),
+ title: (record ? _t("Open: ") : _t("Create ")) + (ev.target.string || data.field.string),
}).open();
},
/**
* Open an existing record in a form view dialog
*
* @private
- * @param {OdooEvent} event
+ * @param {OdooEvent} ev
*/
- _onOpenRecord: function (event) {
- event.stopPropagation();
+ _onOpenRecord: function (ev) {
+ ev.stopPropagation();
var self = this;
- var record = this.model.get(event.data.id, {raw: true});
+ var record = this.model.get(ev.data.id, {raw: true});
new dialogs.FormViewDialog(self, {
- context: event.data.context,
- fields_view: event.data.fields_view,
- on_saved: event.data.on_saved,
- on_remove: event.data.on_remove,
- readonly: event.data.readonly,
- deletable: event.data.deletable,
+ context: ev.data.context,
+ fields_view: ev.data.fields_view,
+ on_saved: ev.data.on_saved,
+ on_remove: ev.data.on_remove,
+ readonly: ev.data.readonly,
+ deletable: ev.data.deletable,
res_id: record.res_id,
res_model: record.model,
- title: _t("Open: ") + event.data.string,
+ title: _t("Open: ") + ev.data.string,
}).open();
},
/**
@@ -690,13 +690,13 @@ var FormController = BasicController.extend({
* in a x2many list view
*
* @private
- * @param {OdooEvent} event
+ * @param {OdooEvent} ev
*/
- _onToggleColumnOrder: function (event) {
- event.stopPropagation();
+ _onToggleColumnOrder: function (ev) {
+ ev.stopPropagation();
var self = this;
- this.model.setSort(event.data.id, event.data.name).then(function () {
- var field = event.data.field;
+ this.model.setSort(ev.data.id, ev.data.name).then(function () {
+ var field = ev.data.field;
var state = self.model.get(self.handle);
self.renderer.confirmChange(state, state.id, [field]);
});
diff --git a/addons/web/static/src/js/views/form/form_renderer.js b/addons/web/static/src/js/views/form/form_renderer.js
index c7a32b72ba9..1da3fb97607 100644
--- a/addons/web/static/src/js/views/form/form_renderer.js
+++ b/addons/web/static/src/js/views/form/form_renderer.js
@@ -974,9 +974,14 @@ var FormRenderer = BasicRenderer.extend({
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
- _onActivateNextWidget: function (e) {
- e.stopPropagation();
- var index = this.allFieldWidgets[this.state.id].indexOf(e.data.target);
+
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onActivateNextWidget: function (ev) {
+ ev.stopPropagation();
+ var index = this.allFieldWidgets[this.state.id].indexOf(ev.data.target);
this._activateNextFieldWidget(this.state, index);
},
/**
@@ -1011,9 +1016,9 @@ var FormRenderer = BasicRenderer.extend({
* @private
* @param {MouseEvent} ev
*/
- _onTranslate: function (event) {
- event.preventDefault();
- this.trigger_up('translate', {fieldName: event.target.name, id: this.state.id});
+ _onTranslate: function (ev) {
+ ev.preventDefault();
+ this.trigger_up('translate', {fieldName: ev.target.name, id: this.state.id});
},
});
diff --git a/addons/web/static/src/js/views/graph/graph_controller.js b/addons/web/static/src/js/views/graph/graph_controller.js
index 4e998377fa7..27bd59e476d 100644
--- a/addons/web/static/src/js/views/graph/graph_controller.js
+++ b/addons/web/static/src/js/views/graph/graph_controller.js
@@ -175,16 +175,16 @@ var GraphController = AbstractController.extend(GroupByMenuInterfaceMixin,{
* Do what need to be done when a button from the control panel is clicked.
*
* @private
- * @param {MouseEvent} event
+ * @param {MouseEvent} ev
*/
- _onButtonClick: function (event) {
- var $target = $(event.target);
+ _onButtonClick: function (ev) {
+ var $target = $(ev.target);
var field;
if ($target.hasClass('o_graph_button')) {
this._setMode($target.data('mode'));
} else if ($target.parents('.o_graph_measures_list').length) {
- event.preventDefault();
- event.stopPropagation();
+ ev.preventDefault();
+ ev.stopPropagation();
field = $target.data('field');
this._setMeasure(field);
}
diff --git a/addons/web/static/src/js/views/graph/graph_renderer.js b/addons/web/static/src/js/views/graph/graph_renderer.js
index 0517e14c876..e68a4446ba5 100644
--- a/addons/web/static/src/js/views/graph/graph_renderer.js
+++ b/addons/web/static/src/js/views/graph/graph_renderer.js
@@ -68,6 +68,11 @@ return AbstractRenderer.extend({
this._super.apply(this, arguments);
this.isInDOM = false;
},
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
/**
* @override
* @param {Object} state
diff --git a/addons/web/static/src/js/views/kanban/kanban_controller.js b/addons/web/static/src/js/views/kanban/kanban_controller.js
index 6ffbad9612a..25fb88c7bd4 100644
--- a/addons/web/static/src/js/views/kanban/kanban_controller.js
+++ b/addons/web/static/src/js/views/kanban/kanban_controller.js
@@ -206,11 +206,11 @@ var KanbanController = BasicController.extend({
* a group and to update the renderer
*
* @private
- * @param {OdooEvent} event
+ * @param {OdooEvent} ev
*/
- _onAddColumn: function (event) {
+ _onAddColumn: function (ev) {
var self = this;
- this.model.createGroup(event.data.value, this.handle).then(function () {
+ this.model.createGroup(ev.data.value, this.handle).then(function () {
var state = self.model.get(self.handle, {raw: true});
var ids = _.pluck(state.data, 'res_id').filter(_.isNumber);
return self._resequenceColumns(ids);
@@ -223,15 +223,15 @@ var KanbanController = BasicController.extend({
},
/**
* @private
- * @param {OdooEvent} event
+ * @param {OdooEvent} ev
*/
- _onAddRecordToColumn: function (event) {
+ _onAddRecordToColumn: function (ev) {
var self = this;
- var record = event.data.record;
- var column = event.target;
+ var record = ev.data.record;
+ var column = ev.target;
this.alive(this.model.moveRecord(record.db_id, column.db_id, this.handle))
.then(function (column_db_ids) {
- return self._resequenceRecords(column.db_id, event.data.ids)
+ return self._resequenceRecords(column.db_id, ev.data.ids)
.then(function () {
_.each(column_db_ids, function (db_id) {
var data = self.model.get(db_id);
@@ -242,12 +242,12 @@ var KanbanController = BasicController.extend({
},
/**
* @private
- * @param {OdooEvent} event
+ * @param {OdooEvent} ev
*/
- _onButtonClicked: function (event) {
- event.stopPropagation();
- var attrs = event.data.attrs;
- var record = event.data.record;
+ _onButtonClicked: function (ev) {
+ ev.stopPropagation();
+ var attrs = ev.data.attrs;
+ var record = ev.data.record;
if (attrs.context) {
attrs.context = new Context(attrs.context)
.set_eval_context({
@@ -264,7 +264,7 @@ var KanbanController = BasicController.extend({
model: record.model,
resIDs: record.res_ids,
},
- on_closed: this._reloadAfterButtonClick.bind(this, event.target, event.data),
+ on_closed: this._reloadAfterButtonClick.bind(this, ev.target, ev.data),
});
},
/**
@@ -294,28 +294,28 @@ var KanbanController = BasicController.extend({
* Moves the focus from the controller buttons to the first kanban record
*
* @private
- * @param {jQueryEvent} e
+ * @param {jQueryEvent} ev
*/
- _onButtonsKeyDown: function (e) {
- switch(e.keyCode) {
+ _onButtonsKeyDown: function (ev) {
+ switch(ev.keyCode) {
case $.ui.keyCode.DOWN:
this.$('.o_kanban_record:first').focus();
}
},
/**
* @private
- * @param {OdooEvent} event
+ * @param {OdooEvent} ev
*/
- _onColumnResequence: function (event) {
- this._resequenceRecords(event.target.db_id, event.data.ids);
+ _onColumnResequence: function (ev) {
+ this._resequenceRecords(ev.target.db_id, ev.data.ids);
},
/**
* @private
- * @param {OdooEvent} event
+ * @param {OdooEvent} ev
*/
- _onDeleteColumn: function (event) {
+ _onDeleteColumn: function (ev) {
var self = this;
- var column = event.target;
+ var column = ev.target;
var state = this.model.get(this.handle, {raw: true});
var relatedModelName = state.fields[state.groupedBy[0]].relation;
this.model
@@ -334,26 +334,26 @@ var KanbanController = BasicController.extend({
* lazy loaded)
*
* @private
- * @param {OdooEvent} event
+ * @param {OdooEvent} ev
*/
- _onLoadColumnRecords: function (event) {
+ _onLoadColumnRecords: function (ev) {
var self = this;
- this.model.loadColumnRecords(event.data.columnID).then(function (dbID) {
+ this.model.loadColumnRecords(ev.data.columnID).then(function (dbID) {
var data = self.model.get(dbID);
self.renderer.updateColumn(dbID, data);
self._updateEnv();
- if (event.data.onSuccess) {
- event.data.onSuccess();
+ if (ev.data.onSuccess) {
+ ev.data.onSuccess();
}
});
},
/**
* @private
- * @param {OdooEvent} event
+ * @param {OdooEvent} ev
*/
- _onLoadMore: function (event) {
+ _onLoadMore: function (ev) {
var self = this;
- var column = event.target;
+ var column = ev.target;
this.model.loadMore(column.db_id).then(function (db_id) {
var data = self.model.get(db_id);
self.renderer.updateColumn(db_id, data);
@@ -416,34 +416,34 @@ var KanbanController = BasicController.extend({
},
/**
* @private
- * @param {OdooEvent} event
+ * @param {OdooEvent} ev
*/
- _onRecordDelete: function (event) {
- this._deleteRecords([event.data.id]);
+ _onRecordDelete: function (ev) {
+ this._deleteRecords([ev.data.id]);
},
/**
* @private
- * @param {OdooEvent} event
+ * @param {OdooEvent} ev
*/
- _onResequenceColumn: function (event) {
+ _onResequenceColumn: function (ev) {
var self = this;
- this._resequenceColumns(event.data.ids).then(function () {
+ this._resequenceColumns(ev.data.ids).then(function () {
self._updateEnv();
});
},
/**
* @private
- * @param {OdooEvent} event
- * @param {boolean} [event.data.openQuickCreate=false] if true, opens the
+ * @param {OdooEvent} ev
+ * @param {boolean} [ev.data.openQuickCreate=false] if true, opens the
* QuickCreate in the toggled column (it assumes that we are opening it)
*/
- _onToggleColumn: function (event) {
+ _onToggleColumn: function (ev) {
var self = this;
- var column = event.target;
+ var column = ev.target;
this.model.toggleGroup(column.db_id).then(function (db_id) {
var data = self.model.get(db_id);
var options = {
- openQuickCreate: !!event.data.openQuickCreate,
+ openQuickCreate: !!ev.data.openQuickCreate,
};
self.renderer.updateColumn(db_id, data, options);
self._updateEnv();
@@ -464,12 +464,12 @@ var KanbanController = BasicController.extend({
* Allow the user to archive/restore all the records of a column.
*
* @private
- * @param {OdooEvent} event
+ * @param {OdooEvent} ev
*/
- _onToggleActiveRecords: function (event) {
+ _onToggleActiveRecords: function (ev) {
var self = this;
- var active = !event.data.archive;
- var column = event.target;
+ var active = !ev.data.archive;
+ var column = ev.target;
var recordIds = _.pluck(column.records, 'db_id');
if (recordIds.length) {
this.model
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 3dc0cae5540..32101d0cd00 100644
--- a/addons/web/static/src/js/views/kanban/kanban_renderer.js
+++ b/addons/web/static/src/js/views/kanban/kanban_renderer.js
@@ -2,10 +2,10 @@ odoo.define('web.KanbanRenderer', function (require) {
"use strict";
var BasicRenderer = require('web.BasicRenderer');
+var ColumnQuickCreate = require('web.kanban_column_quick_create');
var core = require('web.core');
var KanbanColumn = require('web.KanbanColumn');
var KanbanRecord = require('web.KanbanRecord');
-var ColumnQuickCreate = require('web.kanban_column_quick_create');
var QWeb = require('web.QWeb');
var session = require('web.session');
var utils = require('web.utils');
@@ -153,7 +153,10 @@ var KanbanRenderer = BasicRenderer.extend({
addQuickCreate: function () {
return this.widgets[0].addQuickCreate();
},
- giveFocus:function() {
+ /**
+ * Focuses the first kanban record
+ */
+ giveFocus: function () {
this.$('.o_kanban_record:first').focus();
},
/**
@@ -251,21 +254,22 @@ var KanbanRenderer = BasicRenderer.extend({
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
+
/**
- * @private
- * @param {DOMElement} currentColumn
- */
- _focusOnNextCard: function(currentCardElement) {
+ * @private
+ * @param {DOMElement} currentColumn
+ */
+ _focusOnNextCard: function (currentCardElement) {
var nextCard = currentCardElement.nextElementSibling;
if (nextCard) {
nextCard.focus();
}
},
/**
- * @private
- * @param {DOMElement} currentColumn
- */
- _focusOnPreviousCard: function(currentCardElement) {
+ * @private
+ * @param {DOMElement} currentColumn
+ */
+ _focusOnPreviousCard: function (currentCardElement) {
var previousCard = currentCardElement.previousElementSibling;
if (previousCard && previousCard.classList.contains("o_kanban_record")) { //previous element might be column title
previousCard.focus();
@@ -506,6 +510,7 @@ var KanbanRenderer = BasicRenderer.extend({
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
+
/**
* Closes the opened quick create widgets in columns
*
@@ -519,10 +524,10 @@ var KanbanRenderer = BasicRenderer.extend({
},
/**
* @private
- * @param {OdooEvent} event
+ * @param {OdooEvent} ev
*/
- _onQuickCreateColumnUpdated: function (event) {
- event.stopPropagation();
+ _onQuickCreateColumnUpdated: function (ev) {
+ ev.stopPropagation();
this._toggleNoContentHelper();
},
/**
diff --git a/addons/web/static/src/js/views/list/list_controller.js b/addons/web/static/src/js/views/list/list_controller.js
index 3691903c079..612cc1eee02 100644
--- a/addons/web/static/src/js/views/list/list_controller.js
+++ b/addons/web/static/src/js/views/list/list_controller.js
@@ -18,6 +18,11 @@ var _t = core._t;
var qweb = core.qweb;
var ListController = BasicController.extend({
+ /**
+ * This key contains the name of the buttons template to render on top of
+ * the list view. It can be overridden to add buttons in specific child views.
+ */
+ buttons_template: 'ListView.buttons',
custom_events: _.extend({}, BasicController.prototype.custom_events, {
add_record: '_onAddRecord',
button_clicked: '_onButtonClicked',
@@ -105,11 +110,6 @@ var ListController = BasicController.extend({
});
},
/**
- * This key contains the name of the buttons template to render on top of
- * the form view. It can be overridden to add buttons in specific child views.
- */
- buttons_template: 'ListView.buttons',
- /**
* Display and bind all buttons in the control panel
*
* Note: clicking on the "Save" button does nothing special. Indeed, all
@@ -154,13 +154,13 @@ var ListController = BasicController.extend({
label: _t("Archive"),
callback: function () {
Dialog.confirm(self, _t("Are you sure that you want to archive all the selected records?"), {
- confirm_callback: self._onToggleArchiveState.bind(self, true),
+ confirm_callback: self._toggleArchiveState.bind(self, true),
});
}
});
other.push({
label: _t("Unarchive"),
- callback: this._onToggleArchiveState.bind(this, false)
+ callback: this._toggleArchiveState.bind(this, false)
});
}
if (this.is_action_enabled('delete')) {
@@ -351,6 +351,15 @@ var ListController = BasicController.extend({
}
},
/**
+ * Called when clicking on 'Archive' or 'Unarchive' in the sidebar.
+ *
+ * @private
+ * @param {boolean} archive
+ */
+ _toggleArchiveState: function (archive) {
+ this._archive(this.selectedRecords, archive);
+ },
+ /**
* Display the sidebar (the 'action' menu in the control panel) if we have
* some selected records.
*/
@@ -387,25 +396,25 @@ var ListController = BasicController.extend({
* Add a record to the list
*
* @private
- * @param {OdooEvent} event
+ * @param {OdooEvent} ev
*/
- _onAddRecord: function (event) {
- event.stopPropagation();
+ _onAddRecord: function (ev) {
+ ev.stopPropagation();
if (this.activeActions.create) {
this._addRecord();
- } else if (event.data.onFail) {
- event.data.onFail();
+ } else if (ev.data.onFail) {
+ ev.data.onFail();
}
},
/**
* Handles a click on a button by performing its action.
*
* @private
- * @param {OdooEvent} event
+ * @param {OdooEvent} ev
*/
- _onButtonClicked: function (event) {
- event.stopPropagation();
- this._callButtonAction(event.data.attrs, event.data.record);
+ _onButtonClicked: function (ev) {
+ ev.stopPropagation();
+ this._callButtonAction(ev.data.attrs, ev.data.record);
},
/**
* When the user clicks on the 'create' button, two things can happen. We
@@ -413,14 +422,14 @@ var ListController = BasicController.extend({
* mode, or we can edit inline.
*
* @private
- * @param {MouseEvent} event
+ * @param {MouseEvent} ev
*/
- _onCreateRecord: function (event) {
+ _onCreateRecord: function (ev) {
// we prevent the event propagation because we don't want this event to
// trigger a click on the main bus, which would be then caught by the
// list editable renderer and would unselect the newly created row
- if (event) {
- event.stopPropagation();
+ if (ev) {
+ ev.stopPropagation();
}
var state = this.model.get(this.handle, {raw: true});
if (this.editable && !state.groupedBy.length) {
@@ -476,35 +485,23 @@ var ListController = BasicController.extend({
new DataExport(this, record, defaultExportFields).open();
},
/**
- * Called when the renderer displays an editable row and the user tries to
- * leave it -> Saves the record associated to that line.
- *
- * @param {OdooEvent} ev
- */
- _onSaveLine: function (ev) {
- var recordID = ev.data.recordID;
- this.saveRecord(recordID)
- .done(ev.data.onSuccess)
- .fail(ev.data.onFailure);
- },
- /**
* Force a resequence of the records curently on this page.
*
* @private
- * @param {OdooEvent} event
+ * @param {OdooEvent} ev
*/
- _onResequence: function (event) {
+ _onResequence: function (ev) {
var self = this;
this.trigger_up('mutexify', {
action: function () {
var state = self.model.get(self.handle);
- var resIDs = _.map(event.data.rowIDs, function(rowID) {
+ var resIDs = _.map(ev.data.rowIDs, function(rowID) {
return _.findWhere(state.data, {id: rowID}).res_id;
});
var options = {
- offset: event.data.offset,
- field: event.data.handleField,
+ offset: ev.data.offset,
+ field: ev.data.handleField,
};
return self.model.resequence(self.modelName, resIDs, self.handle, options).then(function () {
self._updateEnv();
@@ -515,31 +512,27 @@ var ListController = BasicController.extend({
});
},
/**
- * When the current selection changes (by clicking on the checkboxes on the
- * left), we need to display (or hide) the 'sidebar'.
+ * Called when the renderer displays an editable row and the user tries to
+ * leave it -> Saves the record associated to that line.
*
- * @private
- * @param {OdooEvent} event
- */
- _onSelectionChanged: function (event) {
- this.selectedRecords = event.data.selection;
- this._toggleSidebar();
- },
- /**
- * @override
+ * @param {OdooEvent} ev
*/
- _onSidebarDataAsked: function (event) {
- var env = this._getSidebarEnv();
- event.data.callback(env);
+ _onSaveLine: function (ev) {
+ var recordID = ev.data.recordID;
+ this.saveRecord(recordID)
+ .done(ev.data.onSuccess)
+ .fail(ev.data.onFailure);
},
/**
- * Called when clicking on 'Archive' or 'Unarchive' in the sidebar.
+ * When the current selection changes (by clicking on the checkboxes on the
+ * left), we need to display (or hide) the 'sidebar'.
*
* @private
- * @param {boolean} archive
+ * @param {OdooEvent} ev
*/
- _onToggleArchiveState: function (archive) {
- this._archive(this.selectedRecords, archive);
+ _onSelectionChanged: function (ev) {
+ this.selectedRecords = ev.data.selection;
+ this._toggleSidebar();
},
/**
* When the user clicks on one of the sortable column headers, we need to
@@ -547,16 +540,16 @@ var ListController = BasicController.extend({
* rerender the view.
*
* @private
- * @param {OdooEvent} event
+ * @param {OdooEvent} ev
*/
- _onToggleColumnOrder: function (event) {
- event.stopPropagation();
+ _onToggleColumnOrder: function (ev) {
+ ev.stopPropagation();
var data = this.model.get(this.handle);
if (!data.groupedBy) {
this.pager.updateState({current_min: 1});
}
var self = this;
- this.model.setSort(data.id, event.data.name).then(function () {
+ this.model.setSort(data.id, ev.data.name).then(function () {
self.update({});
});
},
@@ -566,11 +559,11 @@ var ListController = BasicController.extend({
* renderer.
*
* @private
- * @param {OdooEvent} event
+ * @param {OdooEvent} ev
*/
- _onToggleGroup: function (event) {
+ _onToggleGroup: function (ev) {
this.model
- .toggleGroup(event.data.group.id)
+ .toggleGroup(ev.data.group.id)
.then(this.update.bind(this, {}, {keepSelection: true, reload: false}));
},
});
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 b343f044bc2..87542170dd4 100644
--- a/addons/web/static/src/js/views/list/list_renderer.js
+++ b/addons/web/static/src/js/views/list/list_renderer.js
@@ -739,24 +739,24 @@ var ListRenderer = BasicRenderer.extend({
* a cell using the keyboard, if he presses enter, enter the model represented by the line
*
* @private
- * @param {KeyboardEvent} e
+ * @param {KeyboardEvent} ev
*/
- _onKeyDown: function (e) {
+ _onKeyDown: function (ev) {
if (!this.editable) {
- switch (e.which) {
+ switch (ev.which) {
case $.ui.keyCode.DOWN:
- $(e.currentTarget).next().find('input').focus();
- e.preventDefault();
+ $(ev.currentTarget).next().find('input').focus();
+ ev.preventDefault();
break;
case $.ui.keyCode.UP:
- $(e.currentTarget).prev().find('input').focus();
- e.preventDefault();
+ $(ev.currentTarget).prev().find('input').focus();
+ ev.preventDefault();
break;
case $.ui.keyCode.ENTER:
- e.preventDefault();
- var id = $(e.currentTarget).data('id');
+ ev.preventDefault();
+ var id = $(ev.currentTarget).data('id');
if (id) {
- this.trigger_up('open_record', { id: id, target: e.target });
+ this.trigger_up('open_record', { id: id, target: ev.target });
}
break;
}
@@ -764,43 +764,43 @@ var ListRenderer = BasicRenderer.extend({
},
/**
* @private
- * @param {MouseEvent} event
+ * @param {MouseEvent} ev
*/
- _onRowClicked: function (event) {
+ _onRowClicked: function (ev) {
// The special_click property explicitely allow events to bubble all
// the way up to bootstrap's level rather than being stopped earlier.
- if (!$(event.target).prop('special_click')) {
- var id = $(event.currentTarget).data('id');
+ if (!$(ev.target).prop('special_click')) {
+ var id = $(ev.currentTarget).data('id');
if (id) {
- this.trigger_up('open_record', { id: id, target: event.target });
+ this.trigger_up('open_record', { id: id, target: ev.target });
}
}
},
/**
* @private
- * @param {MouseEvent} event
+ * @param {MouseEvent} ev
*/
- _onSelectRecord: function (event) {
- event.stopPropagation();
+ _onSelectRecord: function (ev) {
+ ev.stopPropagation();
this._updateSelection();
- if (!$(event.currentTarget).find('input').prop('checked')) {
+ if (!$(ev.currentTarget).find('input').prop('checked')) {
this.$('thead .o_list_record_selector input').prop('checked', false);
}
},
/**
* @private
- * @param {MouseEvent} event
+ * @param {MouseEvent} ev
*/
- _onSortColumn: function (event) {
- var name = $(event.currentTarget).data('name');
+ _onSortColumn: function (ev) {
+ var name = $(ev.currentTarget).data('name');
this.trigger_up('toggle_column_order', { id: this.state.id, name: name });
},
/**
* @private
- * @param {MouseEvent} event
+ * @param {MouseEvent} ev
*/
- _onToggleGroup: function (event) {
- var group = $(event.currentTarget).data('group');
+ _onToggleGroup: function (ev) {
+ var group = $(ev.currentTarget).data('group');
if (group.count) {
this.trigger_up('toggle_group', { group: group });
}
@@ -810,10 +810,10 @@ var ListRenderer = BasicRenderer.extend({
* to toggle its status.
*
* @private
- * @param {MouseEvent} event
+ * @param {MouseEvent} ev
*/
- _onToggleSelection: function (event) {
- var checked = $(event.currentTarget).prop('checked') || false;
+ _onToggleSelection: function (ev) {
+ var checked = $(ev.currentTarget).prop('checked') || false;
this.$('tbody .o_list_record_selector input:not(":disabled")').prop('checked', checked);
this._updateSelection();
},
diff --git a/addons/web/static/src/js/views/pivot/pivot_controller.js b/addons/web/static/src/js/views/pivot/pivot_controller.js
index 48b5fb033fa..9dfe5dc11b5 100644
--- a/addons/web/static/src/js/views/pivot/pivot_controller.js
+++ b/addons/web/static/src/js/views/pivot/pivot_controller.js
@@ -208,10 +208,10 @@ var PivotController = AbstractController.extend({
* current measures, or a request to flip/expand/download data.
*
* @private
- * @param {MouseEvent} event
+ * @param {MouseEvent} ev
*/
- _onButtonClick: function (event) {
- var $target = $(event.target);
+ _onButtonClick: function (ev) {
+ var $target = $(ev.target);
if ($target.hasClass('o_pivot_flip_button')) {
this.model.flip();
this.update({}, {reload: false});
@@ -223,8 +223,8 @@ var PivotController = AbstractController.extend({
}
if ($target.parents('.o_pivot_measures_list').length) {
var field = $target.data('field');
- event.preventDefault();
- event.stopPropagation();
+ ev.preventDefault();
+ ev.stopPropagation();
this.model
.toggleMeasure(field)
.then(this.update.bind(this, {}, {reload: false}));
@@ -239,10 +239,10 @@ var PivotController = AbstractController.extend({
* corresponding record.
*
* @private
- * @param {MouseEvent} event
+ * @param {MouseEvent} ev
*/
- _onCellClick: function (event) {
- var $target = $(event.currentTarget);
+ _onCellClick: function (ev) {
+ var $target = $(ev.currentTarget);
if ($target.hasClass('o_pivot_header_cell_closed') ||
$target.hasClass('o_pivot_header_cell_opened') ||
$target.hasClass('o_empty') ||
@@ -281,10 +281,10 @@ var PivotController = AbstractController.extend({
* clicked header, if a corresponging groupby has already been selected.
*
* @private
- * @param {MouseEvent} event
+ * @param {MouseEvent} ev
*/
- _onClosedHeaderClick: function (event) {
- var $target = $(event.target);
+ _onClosedHeaderClick: function (ev) {
+ var $target = $(ev.target);
var id = $target.data('id');
var header = this.model.getHeader(id);
var groupbys = header.root.groupbys;
@@ -297,22 +297,22 @@ var PivotController = AbstractController.extend({
this.lastHeaderSelected = id;
var position = $target.position();
var top = position.top + $target.height();
- var left = position.left + event.offsetX;
+ var left = position.left + ev.offsetX;
this._renderFieldSelection(top, left);
- event.stopPropagation();
+ ev.stopPropagation();
}
},
/**
* This handler is called when the user selects a field in the dropdown menu
*
* @private
- * @param {MouseEvent} event
+ * @param {MouseEvent} ev
*/
- _onFieldMenuSelection: function (event) {
- event.preventDefault();
- var $target = $(event.target);
+ _onFieldMenuSelection: function (ev) {
+ ev.preventDefault();
+ var $target = $(ev.target);
if ($target.hasClass('disabled')) {
- event.stopPropagation();
+ ev.stopPropagation();
return;
}
var field = $target.data('field');
@@ -329,10 +329,10 @@ var PivotController = AbstractController.extend({
* If the user clicks on a measure row, we can perform an in-memory sort
*
* @private
- * @param {MouseEvent} event
+ * @param {MouseEvent} ev
*/
- _onMeasureRowClick: function (event) {
- var $target = $(event.target);
+ _onMeasureRowClick: function (ev) {
+ var $target = $(ev.target);
var col_id = $target.data('id');
var measure = $target.data('measure');
var isAscending = $target.hasClass('o_pivot_measure_row_sorted_asc');
@@ -344,16 +344,15 @@ var PivotController = AbstractController.extend({
* happens, we want to close the header, then redisplay the view.
*
* @private
- * @param {MouseEvent} event
+ * @param {MouseEvent} ev
*/
- _onOpenHeaderClick: function (event) {
- event.preventDefault();
- event.stopImmediatePropagation();
- var headerID = $(event.target).data('id');
+ _onOpenHeaderClick: function (ev) {
+ ev.preventDefault();
+ ev.stopImmediatePropagation();
+ var headerID = $(ev.target).data('id');
this.model.closeHeader(headerID);
this.update({}, {reload: false});
},
-
});
return PivotController;
diff --git a/addons/web/static/src/js/views/pivot/pivot_renderer.js b/addons/web/static/src/js/views/pivot/pivot_renderer.js
index 68f2f4bf86d..a68d91330a9 100644
--- a/addons/web/static/src/js/views/pivot/pivot_renderer.js
+++ b/addons/web/static/src/js/views/pivot/pivot_renderer.js
@@ -280,13 +280,12 @@ var PivotRenderer = AbstractRenderer.extend({
/**
* @private
- * @param {MouseEvent} event
+ * @param {MouseEvent} ev
*/
- _onTdHover: function (event) {
+ _onTdHover: function (ev) {
var $td = $(event.target);
$td.closest('table').find('col:eq(' + $td.index()+')').toggleClass('hover');
- }
-
+ },
});
return PivotRenderer;
diff --git a/addons/web/static/src/js/views/view_utils.js b/addons/web/static/src/js/views/view_utils.js
new file mode 100644
index 00000000000..57ae9547d2f
--- /dev/null
+++ b/addons/web/static/src/js/views/view_utils.js
@@ -0,0 +1,47 @@
+odoo.define('web.viewUtils', function () {
+"use strict";
+
+var utils = {
+ /**
+ * Returns the value of a group dataPoint, i.e. the value of the groupBy
+ * field for the records in that group.
+ *
+ * @param {Object} group dataPoint of type list, corresponding to a group
+ * @param {string} groupByField the name of the groupBy field
+ * @returns {string | integer | false}
+ */
+ getGroupValue: function (group, groupByField) {
+ var groupedByField = group.fields[groupByField];
+ switch (groupedByField.type) {
+ case 'many2one':
+ return group.res_id || false;
+ case 'selection':
+ var descriptor = _.findWhere(groupedByField.selection, group.value);
+ return descriptor && descriptor[0];
+ default:
+ return group.value;
+ }
+ },
+ /**
+ * States whether or not the quick create feature is available for the given
+ * datapoint, depending on its groupBy field.
+ *
+ * @param {Object} list dataPoint of type list
+ * @returns {Boolean} true iff the kanban quick create feature is available
+ */
+ isQuickCreateEnabled: function (list) {
+ var groupByField = list.groupedBy[0] && list.groupedBy[0].split(':')[0];
+ if (!groupByField) {
+ return false;
+ }
+ var availableTypes = ['char', 'boolean', 'many2one', 'selection'];
+ if (!_.contains(availableTypes, list.fields[groupByField].type)) {
+ return false;
+ }
+ return true;
+ },
+};
+
+return utils;
+
+});
diff --git a/addons/web/static/src/js/widgets/model_field_selector.js b/addons/web/static/src/js/widgets/model_field_selector.js
index 68539d4502b..eaff737b673 100644
--- a/addons/web/static/src/js/widgets/model_field_selector.js
+++ b/addons/web/static/src/js/widgets/model_field_selector.js
@@ -40,7 +40,10 @@ var ModelFieldSelector = Widget.extend({
"click li.o_field_selector_select_button": "_onLastFieldClick",
// Handle a direct change in the debug input
- "change input": "_onInputChange",
+ "change input.o_field_selector_debug": "_onDebugInputChange",
+
+ // Handle a change in the search input
+ "keyup .o_field_selector_search > input": "_onSearchInputChange",
// Handle keyboard and mouse navigation to build the field chain
"mouseover li.o_field_selector_item": "_onItemHover",
@@ -68,6 +71,8 @@ var ModelFieldSelector = Widget.extend({
* the fields itself)
* @param {boolean|function} [options.followRelations=true]
* true if can follow relation when building the chain
+ * @param {boolean} [options.showSearchInput=false]
+ * true to show a search input to filter displayed fields
* @param {boolean} [options.debugMode=false]
* true if the widget is in debug mode, false otherwise
*/
@@ -84,6 +89,7 @@ var ModelFieldSelector = Widget.extend({
filter: function () {return true;},
followRelations: true,
debugMode: false,
+ showSearchInput: false,
}, options || {});
this.options.filters = _.extend({
searchable: true,
@@ -101,6 +107,8 @@ var ModelFieldSelector = Widget.extend({
if (!this.options.readonly) {
_.extend(this.events, this.editionEvents);
}
+
+ this.searchValue = '';
},
/**
* @see Widget.willStart()
@@ -119,7 +127,8 @@ var ModelFieldSelector = Widget.extend({
start: function () {
this.$value = this.$(".o_field_selector_value");
this.$popover = this.$(".o_field_selector_popover");
- this.$input = this.$popover.find("input");
+ this.$input = this.$popover.find(".o_field_selector_popover_footer > input");
+ this.$searchInput = this.$popover.find(".o_field_selector_search > input");
this.$valid = this.$(".o_field_selector_warning");
this._render();
@@ -179,6 +188,9 @@ var ModelFieldSelector = Widget.extend({
this.dirty = true;
this.chain = this.chain.slice(0, this.pages.length-1);
this.chain.push(fieldName);
+
+ this.searchValue = '';
+ this.$searchInput.val('');
},
/**
* Searches a field in the last page by its name.
@@ -337,6 +349,7 @@ var ModelFieldSelector = Widget.extend({
* @private
*/
_render: function () {
+
// Render the chain value
this.$value.html(core.qweb.render(this.template + ".value", {
chain: this.chain,
@@ -356,8 +369,17 @@ var ModelFieldSelector = Widget.extend({
if (prevField) title = prevField.string;
}
this.$(".o_field_selector_popover_header .o_field_selector_title").text(title);
+
+ var lines = _.filter(page, this.options.filter);
+ if (this.searchValue) {
+ var matches = fuzzy.filter(this.searchValue, _.pluck(lines, 'string'));
+ lines = _.map(_.pluck(matches, 'index'), function (i) {
+ return lines[i];
+ });
+ }
+
this.$(".o_field_selector_page").replaceWith(core.qweb.render(this.template + ".page", {
- lines: _.filter(page, this.options.filter),
+ lines: lines,
followRelations: this.options.followRelations,
debug: this.options.debugMode,
}));
@@ -460,7 +482,7 @@ var ModelFieldSelector = Widget.extend({
/**
* Called when the debug input value is changed -> adapts the chain
*/
- _onInputChange: function () {
+ _onDebugInputChange: function () {
var userChainStr = this.$input.val();
var userChain = userChainStr.split(".");
if (!this.options.followRelations && userChain.length > 1) {
@@ -472,6 +494,13 @@ var ModelFieldSelector = Widget.extend({
}).bind(this));
},
/**
+ * Called when the search input value is changed -> adapts the popover
+ */
+ _onSearchInputChange: function () {
+ this.searchValue = this.$searchInput.val();
+ this._render();
+ },
+ /**
* Called when a popover field button item is hovered -> toggles its
* "active" status
*
@@ -490,6 +519,7 @@ var ModelFieldSelector = Widget.extend({
_onKeydown: function (e) {
if (!this.$popover.is(":visible")) return;
var inputHasFocus = this.$input.is(":focus");
+ var searchInputHasFocus = this.$searchInput.is(":focus");
switch (e.which) {
case $.ui.keyCode.UP:
@@ -535,7 +565,7 @@ var ModelFieldSelector = Widget.extend({
this._hidePopover();
break;
case $.ui.keyCode.ENTER:
- if (inputHasFocus) break;
+ if (inputHasFocus || searchInputHasFocus) break;
e.preventDefault();
this._selectField(this._getLastPageField(this.$("li.o_field_selector_item.active").data("name")));
break;
diff --git a/addons/web/static/src/scss/model_field_selector.scss b/addons/web/static/src/scss/model_field_selector.scss
index 1acd3b0cc90..2f778eddbb9 100644
--- a/addons/web/static/src/scss/model_field_selector.scss
+++ b/addons/web/static/src/scss/model_field_selector.scss
@@ -61,6 +61,9 @@
padding: 0px 35px;
text-align: center;
}
+ .o_field_selector_search {
+ padding-right: 0.4rem;
+ }
.o_field_selector_popover_option {
@include o-position-absolute($top: 0);
padding: 7px 8px 8px 6px;
@@ -71,7 +74,6 @@
}
&.o_field_selector_close {
right: 0;
- border-left: 1px solid darken(theme-color('primary'), 10%);
}
&:hover {
background: darken(theme-color('primary'), 10%);
diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml
index 1eef5047f46..ac9aa1207e4 100644
--- a/addons/web/static/src/xml/base.xml
+++ b/addons/web/static/src/xml/base.xml
@@ -741,12 +741,15 @@
<i class="fa fa-arrow-left o_field_selector_popover_option o_field_selector_prev_page" title="Previous" role="img" aria-label="Previous"/>
<div class="o_field_selector_title"/>
<i class="fa fa-times o_field_selector_popover_option o_field_selector_close" title="Close" role="img" aria-label="Close"/>
+ <div t-if="widget.options.showSearchInput" class="o_field_selector_search">
+ <input type="text" class="o_input"/>
+ </div>
</div>
<div class="o_field_selector_popover_body">
<ul class="o_field_selector_page"/>
</div>
<div t-if="widget.options.debugMode" class="o_field_selector_popover_footer">
- <input type="text" class="o_input"/>
+ <input type="text" class="o_input o_field_selector_debug"/>
</div>
</div>
</div>
diff --git a/addons/web/static/tests/fields/relational_fields/field_many_to_many_tests.js b/addons/web/static/tests/fields/relational_fields/field_many_to_many_tests.js
new file mode 100644
index 00000000000..e782a27cb4a
--- /dev/null
+++ b/addons/web/static/tests/fields/relational_fields/field_many_to_many_tests.js
@@ -0,0 +1,1160 @@
+odoo.define('web.field_many_to_many_tests', function (require) {
+"use strict";
+
+var FormView = require('web.FormView');
+var testUtils = require('web.test_utils');
+var createView = testUtils.createView;
+
+QUnit.module('fields', {}, function () {
+
+ QUnit.module('relational_fields', {
+ beforeEach: function () {
+ this.data = {
+ partner: {
+ fields: {
+ display_name: { string: "Displayed name", type: "char" },
+ foo: { string: "Foo", type: "char", default: "My little Foo Value" },
+ int_field: { string: "int_field", type: "integer", sortable: true },
+ turtles: { string: "one2many turtle field", type: "one2many", relation: 'turtle', relation_field: 'turtle_trululu' },
+ timmy: { string: "pokemon", type: "many2many", relation: 'partner_type' },
+ color: {
+ type: "selection",
+ selection: [['red', "Red"], ['black', "Black"]],
+ default: 'red',
+ string: "Color",
+ },
+ user_id: { string: "User", type: 'many2one', relation: 'user' },
+ reference: {
+ string: "Reference Field", type: 'reference', selection: [
+ ["product", "Product"], ["partner_type", "Partner Type"], ["partner", "Partner"]]
+ },
+ },
+ records: [{
+ id: 1,
+ display_name: "first record",
+ foo: "yop",
+ int_field: 10,
+ turtles: [2],
+ timmy: [],
+ user_id: 17,
+ reference: 'product,37',
+ }, {
+ id: 2,
+ display_name: "second record",
+ foo: "blip",
+ int_field: 9,
+ timmy: [],
+ user_id: 17,
+ }, {
+ id: 4,
+ display_name: "aaa",
+ }],
+ onchanges: {},
+ },
+ product: {
+ fields: {
+ name: { string: "Product Name", type: "char" }
+ },
+ records: [{
+ id: 37,
+ display_name: "xphone",
+ }, {
+ id: 41,
+ display_name: "xpad",
+ }]
+ },
+ partner_type: {
+ fields: {
+ name: { string: "Partner Type", type: "char" },
+ color: { string: "Color index", type: "integer" },
+ },
+ records: [
+ { id: 12, display_name: "gold", color: 2 },
+ { id: 14, display_name: "silver", color: 5 },
+ ]
+ },
+ turtle: {
+ fields: {
+ display_name: { string: "Displayed name", type: "char" },
+ turtle_foo: { string: "Foo", type: "char" },
+ turtle_bar: { string: "Bar", type: "boolean", default: true },
+ partner_ids: { string: "Partner", type: "many2many", relation: 'partner' },
+ },
+ records: [{
+ id: 1,
+ display_name: "leonardo",
+ turtle_foo: "yop",
+ partner_ids: [],
+ }, {
+ id: 2,
+ display_name: "donatello",
+ turtle_foo: "blip",
+ partner_ids: [2, 4],
+ }, {
+ id: 3,
+ display_name: "raphael",
+ turtle_foo: "kawa",
+ partner_ids: [],
+ }],
+ onchanges: {},
+ },
+ user: {
+ fields: {
+ name: { string: "Name", type: "char" },
+ },
+ records: [{
+ id: 17,
+ name: "Aline",
+ }, {
+ id: 19,
+ name: "Christine",
+ }]
+ },
+ };
+ }
+ }, function () {
+ QUnit.module('FieldMany2Many');
+
+ QUnit.test('many2many kanban: edition', function (assert) {
+ assert.expect(32);
+
+ this.data.partner.records[0].timmy = [12, 14];
+ this.data.partner_type.records.push({ id: 15, display_name: "red", color: 6 });
+ this.data.partner_type.records.push({ id: 18, display_name: "yellow", color: 4 });
+ this.data.partner_type.records.push({ id: 21, display_name: "blue", color: 1 });
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="timmy">' +
+ '<kanban>' +
+ '<field name="display_name"/>' +
+ '<templates>' +
+ '<t t-name="kanban-box">' +
+ '<div class="oe_kanban_global_click">' +
+ '<a t-if="!read_only_mode" type="delete" class="fa fa-times float-right delete_icon"/>' +
+ '<span><t t-esc="record.display_name.value"/></span>' +
+ '</div>' +
+ '</t>' +
+ '</templates>' +
+ '</kanban>' +
+ '<form string="Partners">' +
+ '<field name="display_name"/>' +
+ '</form>' +
+ '</field>' +
+ '</form>',
+ archs: {
+ 'partner_type,false,form': '<form string="Types"><field name="display_name"/></form>',
+ 'partner_type,false,list': '<tree string="Types"><field name="display_name"/></tree>',
+ 'partner_type,false,search': '<search string="Types">' +
+ '<field name="name" string="Name"/>' +
+ '</search>',
+ },
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/call_kw/partner_type/write') {
+ assert.strictEqual(args.args[1].display_name, "new name", "should write 'new_name'");
+ }
+ if (route === '/web/dataset/call_kw/partner_type/create') {
+ assert.strictEqual(args.args[0].display_name, "A new type", "should create 'A new type'");
+ }
+ if (route === '/web/dataset/call_kw/partner/write') {
+ var commands = args.args[1].timmy;
+ assert.strictEqual(commands.length, 1, "should have generated one command");
+ assert.strictEqual(commands[0][0], 6, "generated command should be REPLACE WITH");
+ // get the created type's id
+ var createdType = _.findWhere(this.data.partner_type.records, {
+ display_name: "A new type"
+ });
+ var ids = _.sortBy([12, 15, 18].concat(createdType.id), _.identity.bind(_));
+ assert.ok(_.isEqual(_.sortBy(commands[0][2], _.identity.bind(_)), ids),
+ "new value should be " + ids);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // the SelectCreateDialog requests the session, so intercept its custom
+ // event to specify a fake session to prevent it from crashing
+ testUtils.intercept(form, 'get_session', function (event) {
+ event.data.callback({ user_context: {} });
+ });
+
+ assert.ok(!form.$('.o_kanban_view .delete_icon').length,
+ 'delete icon should not be visible in readonly');
+ assert.ok(!form.$('.o_field_many2many .o-kanban-button-new').length,
+ '"Add" button should not be visible in readonly');
+
+ form.$buttons.find('.o_form_button_edit').click();
+
+ assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 2,
+ 'should contain 2 records');
+ assert.strictEqual(form.$('.o_kanban_record:first() span').text(), 'gold',
+ 'display_name of subrecord should be the one in DB');
+ assert.ok(form.$('.o_kanban_view .delete_icon').length,
+ 'delete icon should be visible in edit');
+ assert.ok(form.$('.o_field_many2many .o-kanban-button-new').length,
+ '"Add" button should be visible in edit');
+ assert.strictEqual(form.$('.o_field_many2many .o-kanban-button-new').text().trim(), "Add",
+ 'Create button should have "Add" label');
+
+ // edit existing subrecord
+ form.$('.oe_kanban_global_click:first()').click();
+
+ $('.modal .o_form_view input').val('new name').trigger('input');
+ $('.modal .modal-footer .btn-primary').click(); // save
+ assert.strictEqual(form.$('.o_kanban_record:first() span').text(), 'new name',
+ 'value of subrecord should have been updated');
+
+ // add subrecords
+ // -> single select
+ form.$('.o_field_many2many .o-kanban-button-new').click();
+ assert.ok($('.modal .o_list_view').length, "should have opened a list view in a modal");
+ assert.strictEqual($('.modal .o_list_view tbody .o_list_record_selector').length, 3,
+ "list view should contain 3 records");
+ $('.modal .o_list_view tbody tr:contains(red)').click(); // select red
+ assert.ok(!$('.modal .o_list_view').length, "should have closed the modal");
+ assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 3,
+ 'kanban should now contain 3 records');
+ assert.ok(form.$('.o_kanban_record:contains(red)').length,
+ 'record "red" should be in the kanban');
+
+ // -> multiple select
+ form.$('.o_field_many2many .o-kanban-button-new').click();
+ assert.ok($('.modal .o_select_button').prop('disabled'), "select button should be disabled");
+ assert.strictEqual($('.modal .o_list_view tbody .o_list_record_selector').length, 2,
+ "list view should contain 2 records");
+ $('.modal .o_list_view thead .o_list_record_selector input').click(); // select all
+ $('.modal .o_select_button').click(); // validate selection
+ assert.ok(!$('.modal .o_select_button').prop('disabled'), "select button should be enabled");
+ assert.ok(!$('.modal .o_list_view').length, "should have closed the modal");
+ assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 5,
+ 'kanban should now contain 5 records');
+ // -> created record
+ form.$('.o_field_many2many .o-kanban-button-new').click();
+ $('.modal .modal-footer .btn-primary:nth(1)').click(); // click on 'Create'
+ assert.ok($('.modal .o_form_view.o_form_editable').length,
+ "should have opened a form view in edit mode, in a modal");
+ $('.modal .o_form_view input').val('A new type').trigger('input');
+ $('.modal:nth(1) footer .btn-primary:first()').click(); // click on 'Save & Close'
+ assert.ok(!$('.modal').length, "should have closed both modals");
+ assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 6,
+ 'kanban should now contain 6 records');
+ assert.ok(form.$('.o_kanban_record:contains(A new type)').length,
+ 'the newly created type should be in the kanban');
+
+ // delete subrecords
+ form.$('.o_kanban_record:contains(silver)').click();
+ assert.strictEqual($('.modal .modal-footer .o_btn_remove').length, 1,
+ 'There should be a modal having Remove Button');
+ $('.modal .modal-footer .o_btn_remove').click(); // Click Remove Button of Formview Dialog
+ assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 5,
+ 'should contain 5 records');
+ assert.ok(!form.$('.o_kanban_record:contains(silver)').length,
+ 'the removed record should not be in kanban anymore');
+
+ form.$('.o_kanban_record:contains(blue) .delete_icon').click();
+ assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 4,
+ 'should contain 4 records');
+ assert.ok(!form.$('.o_kanban_record:contains(blue)').length,
+ 'the removed record should not be in kanban anymore');
+
+ // save the record
+ form.$buttons.find('.o_form_button_save').click();
+ form.destroy();
+ });
+
+ QUnit.test('many2many kanban(editable): properly handle create_text node option', function (assert) {
+ assert.expect(1);
+
+ this.data.partner.records[0].timmy = [12];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="timmy" options="{\'create_text\': \'Add timmy\'}" mode="kanban">' +
+ '<kanban>' +
+ '<templates>' +
+ '<t t-name="kanban-box">' +
+ '<div class="oe_kanban_details">' +
+ '<field name="display_name"/>' +
+ '</div>' +
+ '</t>' +
+ '</templates>' +
+ '</kanban>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ assert.strictEqual(form.$('.o_field_many2many[name="timmy"] .o-kanban-button-new').text().trim(),
+ "Add timmy", "In M2M Kanban, Add button should have 'Add timmy' label");
+
+ form.destroy();
+ });
+
+ QUnit.test('many2many kanban: create action disabled', function (assert) {
+ assert.expect(4);
+
+ this.data.partner.records[0].timmy = [12, 14];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="timmy">' +
+ '<kanban create="0">' +
+ '<field name="display_name"/>' +
+ '<templates>' +
+ '<t t-name="kanban-box">' +
+ '<div class="oe_kanban_global_click">' +
+ '<a t-if="!read_only_mode" type="delete" class="fa fa-times float-right delete_icon"/>' +
+ '<span><t t-esc="record.display_name.value"/></span>' +
+ '</div>' +
+ '</t>' +
+ '</templates>' +
+ '</kanban>' +
+ '</field>' +
+ '</form>',
+ archs: {
+ 'partner_type,false,list': '<tree><field name="name"/></tree>',
+ 'partner_type,false,search': '<search>' +
+ '<field name="display_name" string="Name"/>' +
+ '</search>',
+ },
+ res_id: 1,
+ session: { user_context: {} },
+ });
+
+ assert.ok(!form.$('.o-kanban-button-new').length,
+ '"Add" button should not be available in readonly');
+
+ form.$buttons.find('.o_form_button_edit').click();
+
+ assert.ok(form.$('.o-kanban-button-new').length,
+ '"Add" button should be available in edit');
+ assert.ok(form.$('.o_kanban_view .delete_icon').length,
+ 'delete icon should be visible in edit');
+
+ form.$('.o-kanban-button-new').click(); // click on 'Add'
+ assert.strictEqual($('.modal .modal-footer .btn-primary').length, 1, // only button 'Select'
+ '"Create" button should not be available in the modal');
+
+ form.destroy();
+ });
+
+ QUnit.test('many2many list (non editable): edition', function (assert) {
+ assert.expect(27);
+
+ this.data.partner.records[0].timmy = [12, 14];
+ this.data.partner_type.records.push({ id: 15, display_name: "bronze", color: 6 });
+ this.data.partner_type.fields.float_field = { string: 'Float', type: 'float' };
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="timmy">' +
+ '<tree>' +
+ '<field name="display_name"/><field name="float_field"/>' +
+ '</tree>' +
+ '<form string="Partners">' +
+ '<field name="display_name"/>' +
+ '</form>' +
+ '</field>' +
+ '</form>',
+ archs: {
+ 'partner_type,false,list': '<tree><field name="display_name"/></tree>',
+ 'partner_type,false,search': '<search><field name="display_name"/></search>',
+ },
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.method !== 'load_views') {
+ assert.step(_.last(route.split('/')));
+ }
+ if (args.method === 'write' && args.model === 'partner') {
+ assert.deepEqual(args.args[1].timmy, [
+ [6, false, [12, 15]],
+ ]);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.ok(!form.$('.o_list_record_remove').length,
+ 'delete icon should not be visible in readonly');
+ assert.ok(!form.$('.o_field_x2many_list_row_add').length,
+ '"Add an item" should not be visible in readonly');
+
+ form.$buttons.find('.o_form_button_edit').click();
+
+ assert.strictEqual(form.$('.o_list_view td.o_list_number').length, 2,
+ 'should contain 2 records');
+ assert.strictEqual(form.$('.o_list_view tbody td:first()').text(), 'gold',
+ 'display_name of first subrecord should be the one in DB');
+ assert.ok(form.$('.o_list_record_remove').length,
+ 'delete icon should be visible in edit');
+ assert.ok(form.$('.o_field_x2many_list_row_add').length,
+ '"Add an item" should be visible in edit');
+
+ // edit existing subrecord
+ form.$('.o_list_view tbody tr:first()').click();
+
+ $('.modal .o_form_view input').val('new name').trigger('input');
+ $('.modal .modal-footer .btn-primary').click(); // save
+ assert.strictEqual(form.$('.o_list_view tbody td:first()').text(), 'new name',
+ 'value of subrecord should have been updated');
+
+ // add new subrecords
+ form.$('.o_field_x2many_list_row_add a').click();
+ assert.strictEqual($('.modal .o_list_view').length, 1,
+ "a modal should be open");
+ assert.strictEqual($('.modal .o_list_view .o_data_row').length, 1,
+ "the list should contain one row");
+ $('.modal .o_list_view .o_data_row').click(); // select a record
+ assert.strictEqual($('.modal .o_list_view').length, 0,
+ "the modal should be closed");
+ assert.strictEqual(form.$('.o_list_view td.o_list_number').length, 3,
+ 'should contain 3 subrecords');
+
+ // remove subrecords
+ form.$('.o_list_record_remove:nth(1)').click();
+ assert.strictEqual(form.$('.o_list_view td.o_list_number').length, 2,
+ 'should contain 2 subrecords');
+ assert.strictEqual(form.$('.o_list_view .o_data_row td:first').text(), 'new name',
+ 'the updated row still has the correct values');
+
+ // save
+ form.$buttons.find('.o_form_button_save').click();
+ assert.strictEqual(form.$('.o_list_view td.o_list_number').length, 2,
+ 'should contain 2 subrecords');
+ assert.strictEqual(form.$('.o_list_view .o_data_row td:first').text(),
+ 'new name', 'the updated row still has the correct values');
+
+ assert.verifySteps([
+ 'read', // main record
+ 'read', // relational field
+ 'read', // relational record in dialog
+ 'write', // save relational record from dialog
+ 'read', // relational field (updated)
+ 'search_read', // list view in dialog
+ 'read', // relational field (updated)
+ 'write', // save main record
+ 'read', // main record
+ 'read', // relational field
+ ]);
+
+ form.destroy();
+ });
+
+ QUnit.test('many2many list (editable): edition', function (assert) {
+ assert.expect(31);
+
+ this.data.partner.records[0].timmy = [12, 14];
+ this.data.partner_type.records.push({ id: 15, display_name: "bronze", color: 6 });
+ this.data.partner_type.fields.float_field = { string: 'Float', type: 'float' };
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="timmy">' +
+ '<tree editable="top">' +
+ '<field name="display_name"/><field name="float_field"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ archs: {
+ 'partner_type,false,list': '<tree><field name="display_name"/></tree>',
+ 'partner_type,false,search': '<search><field name="display_name"/></search>',
+ },
+ mockRPC: function (route, args) {
+ if (args.method !== 'load_views') {
+ assert.step(_.last(route.split('/')));
+ }
+ if (args.method === 'write') {
+ assert.deepEqual(args.args[1].timmy, [
+ [6, false, [12, 15]],
+ [1, 12, { display_name: 'new name' }],
+ ]);
+ }
+ return this._super.apply(this, arguments);
+ },
+ res_id: 1,
+ });
+
+ assert.ok(!form.$('.o_list_record_remove').length,
+ 'delete icon should not be visible in readonly');
+ assert.ok(!form.$('.o_field_x2many_list_row_add').length,
+ '"Add an item" should not be visible in readonly');
+
+ form.$buttons.find('.o_form_button_edit').click();
+
+ assert.strictEqual(form.$('.o_list_view td.o_list_number').length, 2,
+ 'should contain 2 records');
+ assert.strictEqual(form.$('.o_list_view tbody td:first()').text(), 'gold',
+ 'display_name of first subrecord should be the one in DB');
+ assert.ok(form.$('.o_list_record_remove').length,
+ 'delete icon should be visible in edit');
+ assert.ok(form.$('td.o_list_record_remove button').first().hasClass('fa fa-times'),
+ "should have X icons to remove (unlink) records");
+ assert.ok(form.$('.o_field_x2many_list_row_add').length,
+ '"Add an item" should not visible in edit');
+
+ // edit existing subrecord
+ form.$('.o_list_view tbody td:first()').click();
+ assert.ok(!$('.modal').length,
+ 'in edit, clicking on a subrecord should not open a dialog');
+ assert.ok(form.$('.o_list_view tbody tr:first()').hasClass('o_selected_row'),
+ 'first row should be in edition');
+ form.$('.o_list_view input:first()').val('new name').trigger('input');
+ assert.ok(form.$('.o_list_view .o_data_row:first').hasClass('o_selected_row'),
+ 'first row should still be in edition');
+ assert.strictEqual(form.$('.o_list_view input[name=display_name]').get(0),
+ document.activeElement, 'edited field should still have the focus');
+ form.$el.click(); // click outside the list to validate the row
+ assert.ok(!form.$('.o_list_view tbody tr:first').hasClass('o_selected_row'),
+ 'first row should not be in edition anymore');
+ assert.strictEqual(form.$('.o_list_view tbody td:first()').text(), 'new name',
+ 'value of subrecord should have been updated');
+ assert.verifySteps(['read', 'read']);
+
+ // add new subrecords
+ form.$('.o_field_x2many_list_row_add a').click();
+ assert.strictEqual($('.modal .o_list_view').length, 1,
+ "a modal should be open");
+ assert.strictEqual($('.modal .o_list_view .o_data_row').length, 1,
+ "the list should contain one row");
+ $('.modal .o_list_view .o_data_row').click(); // select a record
+ assert.strictEqual($('.modal .o_list_view').length, 0,
+ "the modal should be closed");
+ assert.strictEqual(form.$('.o_list_view td.o_list_number').length, 3,
+ 'should contain 3 subrecords');
+
+ // remove subrecords
+ form.$('.o_list_record_remove:nth(1)').click();
+ assert.strictEqual(form.$('.o_list_view td.o_list_number').length, 2,
+ 'should contain 2 subrecord');
+ assert.strictEqual(form.$('.o_list_view tbody .o_data_row td:first').text(),
+ 'new name', 'the updated row still has the correct values');
+
+ // save
+ form.$buttons.find('.o_form_button_save').click();
+ assert.strictEqual(form.$('.o_list_view td.o_list_number').length, 2,
+ 'should contain 2 subrecords');
+ assert.strictEqual(form.$('.o_list_view .o_data_row td:first').text(),
+ 'new name', 'the updated row still has the correct values');
+
+ assert.verifySteps([
+ 'read', // main record
+ 'read', // relational field
+ 'search_read', // list view in dialog
+ 'read', // relational field (updated)
+ 'write', // save main record
+ 'read', // main record
+ 'read', // relational field
+ ]);
+
+ form.destroy();
+ });
+
+ QUnit.test('many2many: create & delete attributes', function (assert) {
+ assert.expect(4);
+
+ this.data.partner.records[0].timmy = [12, 14];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="timmy">' +
+ '<tree create="true" delete="true">' +
+ '<field name="color"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+
+ assert.strictEqual(form.$('.o_field_x2many_list_row_add').length, 1, "should have the 'Add an item' link");
+ assert.strictEqual(form.$('.o_list_record_remove').length, 2, "should have the 'Add an item' link");
+
+ form.destroy();
+
+ form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="timmy">' +
+ '<tree create="false" delete="false">' +
+ '<field name="color"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+
+ assert.strictEqual(form.$('.o_field_x2many_list_row_add').length, 0, "should not have the 'Add an item' link");
+ assert.strictEqual(form.$('.o_list_record_remove').length, 0, "should not have the 'Add an item' link");
+
+ form.destroy();
+ });
+
+ QUnit.test('many2many list: create action disabled', function (assert) {
+ assert.expect(2);
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="timmy">' +
+ '<tree create="0">' +
+ '<field name="name"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ assert.ok(!form.$('.o_field_x2many_list_row_add').length,
+ '"Add an item" link should not be available in readonly');
+
+ form.$buttons.find('.o_form_button_edit').click();
+
+ assert.ok(!form.$('.o_field_x2many_list_row_add').length,
+ '"Add an item" link should not be available in edit either');
+
+ form.destroy();
+ });
+
+ QUnit.test('many2many list: list of id as default value', function (assert) {
+ assert.expect(1);
+
+ this.data.partner.fields.turtles.default = [2, 3];
+ this.data.partner.fields.turtles.type = "many2many";
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree>' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ });
+
+ assert.strictEqual(form.$('td.o_data_cell').text(), "blipkawa",
+ "should have loaded default data");
+
+ form.destroy();
+ });
+
+ QUnit.test('many2many checkboxes with default values', function (assert) {
+ assert.expect(7);
+
+ this.data.partner.fields.turtles.default = [3];
+ this.data.partner.fields.turtles.type = "many2many";
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles" widget="many2many_checkboxes">' +
+ '</field>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'create') {
+ assert.deepEqual(args.args[0].turtles, [[6, false, [1]]],
+ "correct values should have been sent to create");
+ }
+ return this._super.apply(this, arguments);
+ }
+ });
+
+ assert.notOk(form.$('.custom-checkbox input').eq(0).prop('checked'),
+ "first checkbox should not be checked");
+ assert.notOk(form.$('.custom-checkbox input').eq(1).prop('checked'),
+ "second checkbox should not be checked");
+ assert.ok(form.$('.custom-checkbox input').eq(2).prop('checked'),
+ "third checkbox should be checked");
+
+ form.$('.custom-checkbox input:checked').click(); // Uncheck default record
+ form.$('.custom-checkbox input').first().click(); // Check first record
+ form.$('.custom-checkbox input').first().click(); // Uncheck first record
+ form.$('.custom-checkbox input').first().click(); // Recheck first record
+
+ assert.ok(form.$('.custom-checkbox input').eq(0).prop('checked'),
+ "first checkbox should be checked");
+ assert.notOk(form.$('.custom-checkbox input').eq(1).prop('checked'),
+ "second checkbox should not be checked");
+ assert.notOk(form.$('.custom-checkbox input').eq(2).prop('checked'),
+ "third checkbox should not be checked");
+
+ form.$buttons.find('.o_form_button_save').click();
+
+ form.destroy();
+ });
+
+ QUnit.test('many2many checkboxes with default values', function (assert) {
+ assert.expect(7);
+
+ this.data.partner.fields.turtles.default = [3];
+ this.data.partner.fields.turtles.type = "many2many";
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles" widget="many2many_checkboxes">' +
+ '</field>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'create') {
+ assert.deepEqual(args.args[0].turtles, [[6, false, [1]]],
+ "correct values should have been sent to create");
+ }
+ return this._super.apply(this, arguments);
+ }
+ });
+
+ assert.notOk(form.$('.custom-checkbox input').eq(0).prop('checked'),
+ "first checkbox should not be checked");
+ assert.notOk(form.$('.custom-checkbox input').eq(1).prop('checked'),
+ "second checkbox should not be checked");
+ assert.ok(form.$('.custom-checkbox input').eq(2).prop('checked'),
+ "third checkbox should be checked");
+
+ form.$('.custom-checkbox input:checked').click(); // Uncheck default record
+ form.$('.custom-checkbox input').first().click(); // Check first record
+ form.$('.custom-checkbox input').first().click(); // Uncheck first record
+ form.$('.custom-checkbox input').first().click(); // Recheck first record
+
+ assert.ok(form.$('.custom-checkbox input').eq(0).prop('checked'),
+ "first checkbox should be checked");
+ assert.notOk(form.$('.custom-checkbox input').eq(1).prop('checked'),
+ "second checkbox should not be checked");
+ assert.notOk(form.$('.custom-checkbox input').eq(2).prop('checked'),
+ "third checkbox should not be checked");
+
+ form.$buttons.find('.o_form_button_save').click();
+
+ form.destroy();
+ });
+
+ QUnit.test('many2many list with x2many: add a record', function (assert) {
+ assert.expect(18);
+
+ this.data.partner_type.fields.m2m = {
+ string: "M2M", type: "many2many", relation: 'turtle',
+ };
+ this.data.partner_type.records[0].m2m = [1, 2];
+ this.data.partner_type.records[1].m2m = [2, 3];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="timmy"/>' +
+ '</form>',
+ res_id: 1,
+ archs: {
+ 'partner_type,false,list': '<tree>' +
+ '<field name="display_name"/>' +
+ '<field name="m2m" widget="many2many_tags"/>' +
+ '</tree>',
+ 'partner_type,false,search': '<search>' +
+ '<field name="display_name" string="Name"/>' +
+ '</search>',
+ },
+ mockRPC: function (route, args) {
+ if (args.method !== 'load_views') {
+ assert.step(_.last(route.split('/')) + ' on ' + args.model);
+ }
+ if (args.model === 'turtle') {
+ assert.step(args.args[0]); // the read ids
+ }
+ return this._super.apply(this, arguments);
+ },
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ form.$('.o_field_x2many_list_row_add a').click();
+ $('.modal .o_data_row:first').click(); // add a first record to the relation
+
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "the record should have been added to the relation");
+ assert.strictEqual(form.$('.o_data_row:first .o_badge_text').text(), 'leonardodonatello',
+ "inner m2m should have been fetched and correctly displayed");
+
+ form.$('.o_field_x2many_list_row_add a').click();
+ $('.modal .o_data_row:first').click(); // add a second record to the relation
+
+ assert.strictEqual(form.$('.o_data_row').length, 2,
+ "the second record should have been added to the relation");
+ assert.strictEqual(form.$('.o_data_row:nth(1) .o_badge_text').text(), 'donatelloraphael',
+ "inner m2m should have been fetched and correctly displayed");
+
+ assert.verifySteps([
+ 'read on partner',
+ 'search_read on partner_type',
+ 'read on turtle',
+ [1, 2, 3],
+ 'read on partner_type',
+ 'read on turtle',
+ [1, 2],
+ 'search_read on partner_type',
+ 'read on turtle',
+ [2, 3],
+ 'read on partner_type',
+ 'read on turtle',
+ [2, 3],
+ ]);
+
+ form.destroy();
+ });
+
+ QUnit.test('many2many with a domain', function (assert) {
+ // The domain specified on the field should not be replaced by the potential
+ // domain the user writes in the dialog, they should rather be concatenated
+ assert.expect(2);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="timmy" domain="[[\'display_name\', \'=\', \'gold\']]"/>' +
+ '</form>',
+ res_id: 1,
+ archs: {
+ 'partner_type,false,list': '<tree>' +
+ '<field name="display_name"/>' +
+ '</tree>',
+ 'partner_type,false,search': '<search>' +
+ '<field name="display_name" string="Name"/>' +
+ '</search>',
+ },
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ form.$('.o_field_x2many_list_row_add a').click();
+ assert.strictEqual($('.modal .o_data_row').length, 1,
+ "should contain only one row (gold)");
+
+ $('.modal .o_searchview_input').trigger({ type: 'keypress', which: 115 }); // s
+ $('.modal .o_searchview_input').trigger({ type: 'keydown', which: 13 }); // enter
+
+ assert.strictEqual($('.modal .o_data_row').length, 0, "should contain no row");
+
+ form.destroy();
+ });
+
+ QUnit.test('many2many list with onchange and edition of a record', function (assert) {
+ assert.expect(8);
+
+ this.data.partner.fields.turtles.type = "many2many";
+ this.data.partner.onchanges.turtles = function () { };
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree>' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ archs: {
+ 'turtle,false,form': '<form string="Turtle Power"><field name="turtle_bar"/></form>',
+ },
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('td.o_data_cell:first').click();
+
+ $('.modal-body input[type="checkbox"]').click();
+ $('.modal .modal-footer .btn-primary').first().click();
+
+ // there is nothing left to save -> should not do a 'write' RPC
+ form.$buttons.find('.o_form_button_save').click();
+
+ assert.verifySteps([
+ 'read', // read initial record (on partner)
+ 'read', // read many2many turtles
+ 'load_views', // load arch of turtles form view
+ 'read', // read missing field when opening record in modal form view
+ 'write', // when saving the modal
+ 'onchange', // onchange should be triggered on partner
+ 'read', // reload many2many
+ ]);
+
+ form.destroy();
+ });
+
+ QUnit.test('onchange with 40+ commands for a many2many', function (assert) {
+ // this test ensures that the basic_model correctly handles more LINK_TO
+ // commands than the limit of the dataPoint (40 for x2many kanban)
+ assert.expect(23);
+
+ // create a lot of partner_types that will be linked by the onchange
+ var commands = [[5]];
+ for (var i = 0; i < 45; i++) {
+ var id = 100 + i;
+ this.data.partner_type.records.push({ id: id, display_name: "type " + id });
+ commands.push([4, id]);
+ }
+ this.data.partner.onchanges = {
+ foo: function (obj) {
+ obj.timmy = commands;
+ },
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="foo"/>' +
+ '<field name="timmy">' +
+ '<kanban>' +
+ '<field name="display_name"/>' +
+ '<templates>' +
+ '<t t-name="kanban-box">' +
+ '<div><t t-esc="record.display_name.value"/></div>' +
+ '</t>' +
+ '</templates>' +
+ '</kanban>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ if (args.method === 'write') {
+ assert.strictEqual(args.args[1].timmy[0][0], 6,
+ "should send a command 6");
+ assert.strictEqual(args.args[1].timmy[0][2].length, 45,
+ "should replace with 45 ids");
+ }
+ return this._super.apply(this, arguments);
+ },
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ assert.verifySteps(['read']);
+
+ form.$('.o_field_widget[name=foo]').val('trigger onchange').trigger('input');
+
+ assert.verifySteps(['read', 'onchange', 'read']);
+ assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(),
+ '1-40 / 45', "pager should be correct");
+ assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40,
+ 'there should be 40 records displayed on page 1');
+
+ form.$('.o_pager_next').click();
+ assert.verifySteps(['read', 'onchange', 'read', 'read']);
+ assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(),
+ '41-45 / 45', "pager should be correct");
+ assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 5,
+ 'there should be 5 records displayed on page 2');
+
+ form.$buttons.find('.o_form_button_save').click();
+
+ assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(),
+ '1-40 / 45', "pager should be correct");
+ assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40,
+ 'there should be 40 records displayed on page 1');
+
+ form.$('.o_pager_next').click();
+ assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(),
+ '41-45 / 45', "pager should be correct");
+ assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 5,
+ 'there should be 5 records displayed on page 2');
+
+ form.$('.o_pager_next').click(); // back to page 1
+ assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(),
+ '1-40 / 45', "pager should be correct");
+ assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40,
+ 'there should be 40 records displayed on page 1');
+
+ form.destroy();
+ });
+
+ QUnit.test('default_get, onchange, onchange on m2m', function (assert) {
+ assert.expect(1);
+
+ this.data.partner.onchanges.int_field = function (obj) {
+ if (obj.int_field === 2) {
+ assert.deepEqual(obj.timmy, [
+ [6, false, [12]],
+ [1, 12, { display_name: 'gold' }]
+ ]);
+ }
+ obj.timmy = [
+ [5],
+ [1, 12, { display_name: 'gold' }]
+ ];
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="timmy">' +
+ '<tree>' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '</field>' +
+ '<field name="int_field"/>' +
+ '</sheet>' +
+ '</form>',
+ });
+
+ form.$('.o_field_widget[name=int_field]').val(2).trigger('input');
+ form.destroy();
+ });
+
+ QUnit.test('widget many2many_tags', function (assert) {
+ assert.expect(1);
+ this.data.turtle.records[0].partner_ids = [2];
+
+ var form = createView({
+ View: FormView,
+ model: 'turtle',
+ data: this.data,
+ arch: '<form string="Turtles">' +
+ '<sheet>' +
+ '<field name="display_name"/>' +
+ '<field name="partner_ids" widget="many2many_tags"/>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ assert.deepEqual(
+ form.$('.o_field_many2manytags.o_field_widget .badge .o_badge_text').attr('title'),
+ 'second record', 'the title should be filled in'
+ );
+
+ form.destroy();
+ });
+
+ QUnit.test('many2many list add *many* records, remove, re-add', function (assert) {
+ assert.expect(5);
+
+ this.data.partner.fields.timmy.domain = [['color', '=', 2]];
+ this.data.partner.fields.timmy.onChange = true;
+ this.data.partner_type.fields.product_ids = { string: "Product", type: "many2many", relation: 'product' };
+
+ for (var i = 0; i < 50; i++) {
+ var new_record_partner_type = { id: 100 + i, display_name: "batch" + i, color: 2 };
+ this.data.partner_type.records.push(new_record_partner_type);
+ }
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="timmy" widget="many2many">' +
+ '<tree>' +
+ '<field name="display_name"/>' +
+ '<field name="product_ids" widget="many2many_tags"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ archs: {
+ 'partner_type,false,list': '<tree><field name="display_name"/></tree>',
+ 'partner_type,false,search': '<search><field name="display_name"/><field name="color"/></search>',
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'get_formview_id') {
+ assert.deepEqual(args.args[0], [1], "should call get_formview_id with correct id");
+ return $.when(false);
+ }
+ return this._super(route, args);
+ },
+ });
+
+ // First round: add 51 records in batch
+ form.$buttons.find('.btn.btn-primary.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ var $modal = $('.modal-lg');
+
+ assert.equal($modal.length, 1,
+ 'There should be one modal');
+
+ $modal.find('thead input[type=checkbox]').click(); //select all the records we created in batch + 'gold'
+
+ $modal.find('.btn.btn-primary.o_select_button').click();
+
+ var m2m_records = form.$('.o_field_many2many.o_field_widget.o_field_x2many.o_field_x2many_list .o_data_cell:not(.o_many2many_tags_cell)');
+ assert.equal(m2m_records.length, 51,
+ 'We should have added all the records present in the search view to the m2m field'); // the 50 in batch + 'gold'
+
+ form.$buttons.find('.btn.btn-primary.o_form_button_save').click();
+
+ // Secound round: remove one record
+ form.$buttons.find('.btn.btn-primary.o_form_button_edit').click();
+ var trash_buttons = form.$('.o_field_many2many.o_field_widget.o_field_x2many.o_field_x2many_list .o_list_record_remove');
+
+ trash_buttons.first().click();
+
+ var pager_limit = form.$('.o_field_many2many.o_field_widget.o_field_x2many.o_field_x2many_list .o_pager_limit');
+ assert.equal(pager_limit.text(), '50',
+ 'We should have 50 records in the m2m field');
+
+ // Third round: re-add 1 records
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ $modal = $('.modal-lg');
+
+ assert.equal($modal.length, 1,
+ 'There should be one modal');
+
+ $modal.find('thead input[type=checkbox]').click();
+
+ $modal.find('.btn.btn-primary.o_select_button').click();
+
+ pager_limit = form.$('.o_field_many2many.o_field_widget.o_field_x2many.o_field_x2many_list .o_pager_limit');
+ assert.equal(pager_limit.text(), '51',
+ 'We should have 51 records in the m2m field');
+
+ form.destroy();
+ });
+ });
+});
+});
diff --git a/addons/web/static/tests/fields/relational_fields/field_many_to_one_tests.js b/addons/web/static/tests/fields/relational_fields/field_many_to_one_tests.js
new file mode 100644
index 00000000000..72044b99d2f
--- /dev/null
+++ b/addons/web/static/tests/fields/relational_fields/field_many_to_one_tests.js
@@ -0,0 +1,3025 @@
+odoo.define('web.field_many_to_one_tests', function (require) {
+"use strict";
+
+var BasicModel = require('web.BasicModel');
+var concurrency = require('web.concurrency');
+var FormView = require('web.FormView');
+var ListView = require('web.ListView');
+var relationalFields = require('web.relational_fields');
+var StandaloneFieldManagerMixin = require('web.StandaloneFieldManagerMixin');
+var testUtils = require('web.test_utils');
+var Widget = require('web.Widget');
+
+var createView = testUtils.createView;
+
+QUnit.module('fields', {}, function () {
+
+ QUnit.module('relational_fields', {
+ beforeEach: function () {
+ this.data = {
+ partner: {
+ fields: {
+ display_name: { string: "Displayed name", type: "char" },
+ foo: { string: "Foo", type: "char", default: "My little Foo Value" },
+ bar: { string: "Bar", type: "boolean", default: true },
+ int_field: { string: "int_field", type: "integer", sortable: true },
+ p: { string: "one2many field", type: "one2many", relation: 'partner', relation_field: 'trululu' },
+ turtles: { string: "one2many turtle field", type: "one2many", relation: 'turtle', relation_field: 'turtle_trululu' },
+ trululu: { string: "Trululu", type: "many2one", relation: 'partner' },
+ timmy: { string: "pokemon", type: "many2many", relation: 'partner_type' },
+ product_id: { string: "Product", type: "many2one", relation: 'product' },
+ color: {
+ type: "selection",
+ selection: [['red', "Red"], ['black', "Black"]],
+ default: 'red',
+ string: "Color",
+ },
+ date: { string: "Some Date", type: "date" },
+ datetime: { string: "Datetime Field", type: 'datetime' },
+ user_id: { string: "User", type: 'many2one', relation: 'user' },
+ reference: {
+ string: "Reference Field", type: 'reference', selection: [
+ ["product", "Product"], ["partner_type", "Partner Type"], ["partner", "Partner"]]
+ },
+ },
+ records: [{
+ id: 1,
+ display_name: "first record",
+ bar: true,
+ foo: "yop",
+ int_field: 10,
+ p: [],
+ turtles: [2],
+ timmy: [],
+ trululu: 4,
+ user_id: 17,
+ reference: 'product,37',
+ }, {
+ id: 2,
+ display_name: "second record",
+ bar: true,
+ foo: "blip",
+ int_field: 9,
+ p: [],
+ timmy: [],
+ trululu: 1,
+ product_id: 37,
+ date: "2017-01-25",
+ datetime: "2016-12-12 10:55:05",
+ user_id: 17,
+ }, {
+ id: 4,
+ display_name: "aaa",
+ bar: false,
+ }],
+ onchanges: {},
+ },
+ product: {
+ fields: {
+ name: { string: "Product Name", type: "char" }
+ },
+ records: [{
+ id: 37,
+ display_name: "xphone",
+ }, {
+ id: 41,
+ display_name: "xpad",
+ }]
+ },
+ partner_type: {
+ fields: {
+ name: { string: "Partner Type", type: "char" },
+ color: { string: "Color index", type: "integer" },
+ },
+ records: [
+ { id: 12, display_name: "gold", color: 2 },
+ { id: 14, display_name: "silver", color: 5 },
+ ]
+ },
+ turtle: {
+ fields: {
+ display_name: { string: "Displayed name", type: "char" },
+ turtle_foo: { string: "Foo", type: "char" },
+ turtle_bar: { string: "Bar", type: "boolean", default: true },
+ turtle_int: { string: "int", type: "integer", sortable: true },
+ turtle_trululu: { string: "Trululu", type: "many2one", relation: 'partner' },
+ turtle_ref: {
+ string: "Reference", type: 'reference', selection: [
+ ["product", "Product"], ["partner", "Partner"]]
+ },
+ product_id: { string: "Product", type: "many2one", relation: 'product', required: true },
+ partner_ids: { string: "Partner", type: "many2many", relation: 'partner' },
+ },
+ records: [{
+ id: 1,
+ display_name: "leonardo",
+ turtle_bar: true,
+ turtle_foo: "yop",
+ partner_ids: [],
+ }, {
+ id: 2,
+ display_name: "donatello",
+ turtle_bar: true,
+ turtle_foo: "blip",
+ turtle_int: 9,
+ partner_ids: [2, 4],
+ }, {
+ id: 3,
+ display_name: "raphael",
+ product_id: 37,
+ turtle_bar: false,
+ turtle_foo: "kawa",
+ turtle_int: 21,
+ partner_ids: [],
+ turtle_ref: 'product,37',
+ }],
+ onchanges: {},
+ },
+ user: {
+ fields: {
+ name: { string: "Name", type: "char" },
+ partner_ids: { string: "one2many partners field", type: "one2many", relation: 'partner', relation_field: 'user_id' },
+ },
+ records: [{
+ id: 17,
+ name: "Aline",
+ partner_ids: [1, 2],
+ }, {
+ id: 19,
+ name: "Christine",
+ }]
+ },
+ };
+ }
+ }, function () {
+ QUnit.module('FieldMany2One');
+
+ QUnit.test('many2ones in form views', function (assert) {
+ assert.expect(5);
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="trululu" string="custom label"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ archs: {
+ 'partner,false,form': '<form string="Partners"><field name="display_name"/></form>',
+ },
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.method === 'get_formview_action') {
+ assert.deepEqual(args.args[0], [4], "should call get_formview_action with correct id");
+ return $.when({
+ res_id: 17,
+ type: 'ir.actions.act_window',
+ target: 'current',
+ res_model: 'res.partner'
+ });
+ }
+ if (args.method === 'get_formview_id') {
+ assert.deepEqual(args.args[0], [4], "should call get_formview_id with correct id");
+ return $.when(false);
+ }
+ return this._super(route, args);
+ },
+ });
+
+ testUtils.intercept(form, 'do_action', function (event) {
+ assert.strictEqual(event.data.action.res_id, 17,
+ "should do a do_action with correct parameters");
+ });
+
+ assert.strictEqual(form.$('a.o_form_uri:contains(aaa)').length, 1,
+ "should contain a link");
+ form.$('a.o_form_uri').click(); // click on the link in readonly mode (should trigger do_action)
+
+ form.$buttons.find('.o_form_button_edit').click();
+
+ form.$('.o_external_button').click(); // click on the external button (should do an RPC)
+
+ assert.strictEqual($('.modal .modal-title').text().trim(), 'Open: custom label',
+ "dialog title should display the custom string label");
+
+ // TODO: test that we can edit the record in the dialog, and that the value is correctly
+ // updated on close
+ form.destroy();
+ });
+
+ QUnit.test('editing a many2one, but not changing anything', function (assert) {
+ assert.expect(2);
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="trululu"/>' +
+ '</sheet>' +
+ '</form>',
+ archs: {
+ 'partner,false,form': '<form string="Partners"><field name="display_name"/></form>',
+ },
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.method === 'get_formview_id') {
+ assert.deepEqual(args.args[0], [4], "should call get_formview_id with correct id");
+ return $.when(false);
+ }
+ return this._super(route, args);
+ },
+ viewOptions: {
+ ids: [1, 2],
+ },
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+
+ // click on the external button (should do an RPC)
+ form.$('.o_external_button').click();
+ // save and close modal
+ $('.modal .modal-footer .btn-primary:first').click();
+ // save form
+ form.$buttons.find('.o_form_button_save').click();
+ // click next on pager
+ form.pager.$('.o_pager_next').click();
+
+ // this checks that the view did not ask for confirmation that the
+ // record is dirty
+ assert.strictEqual(form.pager.$el.text().trim(), '2 / 2',
+ 'pager should be at second page');
+ form.destroy();
+ });
+
+ QUnit.test('context in many2one and default get', function (assert) {
+ assert.expect(1);
+
+ this.data.partner.fields.int_field.default = 14;
+ this.data.partner.fields.trululu.default = 2;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="int_field"/>' +
+ '<field name="trululu" context="{\'blip\':int_field}"/>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'name_get') {
+ assert.strictEqual(args.kwargs.context.blip, 14,
+ 'context should have been properly sent to the nameget rpc');
+ }
+ return this._super(route, args);
+ },
+ });
+ form.destroy();
+ });
+
+ QUnit.test('editing a many2one (with form view opened with external button)', function (assert) {
+ assert.expect(1);
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="trululu"/>' +
+ '</sheet>' +
+ '</form>',
+ archs: {
+ 'partner,false,form': '<form string="Partners"><field name="foo"/></form>',
+ },
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.method === 'get_formview_id') {
+ return $.when(false);
+ }
+ return this._super(route, args);
+ },
+ viewOptions: {
+ ids: [1, 2],
+ },
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+
+ // click on the external button (should do an RPC)
+ form.$('.o_external_button').click();
+
+ $('.modal input[name="foo"]').val('brandon').trigger('input');
+
+ // save and close modal
+ $('.modal .modal-footer .btn-primary:first').click();
+ // save form
+ form.$buttons.find('.o_form_button_save').click();
+ // click next on pager
+ form.pager.$('.o_pager_next').click();
+
+ // this checks that the view did not ask for confirmation that the
+ // record is dirty
+ assert.strictEqual(form.pager.$el.text().trim(), '2 / 2',
+ 'pager should be at second page');
+ form.destroy();
+ });
+
+ QUnit.test('many2ones in form views with show_adress', function (assert) {
+ assert.expect(4);
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field ' +
+ 'name="trululu" ' +
+ 'string="custom label" ' +
+ 'context="{\'search_default_customer\':1, \'show_address\': 1}" ' +
+ 'options="{\'always_reload\': True}"' +
+ '/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'name_get') {
+ return this._super(route, args).then(function (result) {
+ result[0][1] += '\nStreet\nCity ZIP';
+ return result;
+ });
+ }
+ return this._super(route, args);
+ },
+ res_id: 1,
+ });
+
+ assert.strictEqual($('a.o_form_uri').html(), 'aaa<br>Street<br>City ZIP',
+ "input should have a multi-line content in readonly due to show_address");
+ form.$buttons.find('.o_form_button_edit').click();
+ assert.strictEqual(form.$('button.o_external_button:visible').length, 1,
+ "should have an open record button");
+
+ form.$('input.o_input').click();
+
+ assert.strictEqual(form.$('button.o_external_button:visible').length, 1,
+ "should still have an open record button");
+ form.$('input.o_input').trigger('focusout');
+ assert.strictEqual($('.modal button:contains(Create and edit)').length, 0,
+ "there should not be a quick create modal");
+
+ form.destroy();
+ });
+
+ QUnit.test('show_address works in a view embedded in a view of another type', function (assert) {
+ assert.expect(1);
+
+ this.data.turtle.records[1].turtle_trululu = 2;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="display_name"/>' +
+ '<field name="turtles"/>' +
+ '</form>',
+ res_id: 1,
+ archs: {
+ "turtle,false,form": '<form string="T">' +
+ '<field name="display_name"/>' +
+ '<field name="turtle_trululu" context="{\'show_address\': 1}" options="{\'always_reload\': True}"/>' +
+ '</form>',
+ "turtle,false,list": '<tree editable="bottom">' +
+ '<field name="display_name"/>' +
+ '</tree>',
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'name_get') {
+ return this._super(route, args).then(function (result) {
+ if (args.model === 'partner' && args.kwargs.context.show_address) {
+ result[0][1] += '\nrue morgue\nparis 75013';
+ }
+ return result;
+ });
+ }
+ return this._super(route, args);
+ },
+ });
+ // click the turtle field, opens a modal with the turtle form view
+ form.$('.o_data_row:first td.o_data_cell').click();
+
+ assert.strictEqual($('[name="turtle_trululu"]').text(), "second recordrue morgueparis 75013",
+ "The partner's address should be displayed");
+ form.destroy();
+ });
+
+ QUnit.test('many2one data is reloaded if there is a context to take into account', function (assert) {
+ assert.expect(1);
+
+ this.data.turtle.records[1].turtle_trululu = 2;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="display_name"/>' +
+ '<field name="turtles"/>' +
+ '</form>',
+ res_id: 1,
+ archs: {
+ "turtle,false,form": '<form string="T">' +
+ '<field name="display_name"/>' +
+ '<field name="turtle_trululu" context="{\'show_address\': 1}" options="{\'always_reload\': True}"/>' +
+ '</form>',
+ "turtle,false,list": '<tree editable="bottom">' +
+ '<field name="display_name"/>' +
+ '<field name="turtle_trululu"/>' +
+ '</tree>',
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'name_get') {
+ return this._super(route, args).then(function (result) {
+ if (args.model === 'partner' && args.kwargs.context.show_address) {
+ result[0][1] += '\nrue morgue\nparis 75013';
+ }
+ return result;
+ });
+ }
+ return this._super(route, args);
+ },
+ });
+ // click the turtle field, opens a modal with the turtle form view
+ form.$('.o_data_row:first').click();
+
+ assert.strictEqual($('.modal [name=turtle_trululu]').text(), "second recordrue morgueparis 75013",
+ "The partner's address should be displayed");
+ form.destroy();
+ });
+
+ QUnit.test('many2ones in form views with search more', function (assert) {
+ assert.expect(3);
+ this.data.partner.records.push({
+ id: 5,
+ display_name: "Partner 4",
+ }, {
+ id: 6,
+ display_name: "Partner 5",
+ }, {
+ id: 7,
+ display_name: "Partner 6",
+ }, {
+ id: 8,
+ display_name: "Partner 7",
+ }, {
+ id: 9,
+ display_name: "Partner 8",
+ }, {
+ id: 10,
+ display_name: "Partner 9",
+ });
+ this.data.partner.fields.datetime.searchable = true;
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="trululu"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ archs: {
+ 'partner,false,list': '<tree><field name="display_name"/></tree>',
+ 'partner,false,search': '<search><field name="datetime"/></search>',
+ },
+ res_id: 1,
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ var $dropdown = form.$('.o_field_many2one input').autocomplete('widget');
+ form.$('.o_field_many2one input').click();
+ $dropdown.find('.o_m2o_dropdown_option:contains(Search)').mouseenter().click(); // Open Search More
+
+ assert.strictEqual($('tr.o_data_row').length, 9, "should display 9 records");
+
+ $('button:contains(Filters)').click();
+ $('.o_add_custom_filter:visible').click(); // Add a custom filter, datetime field is selected
+ assert.strictEqual($('.o_filter_condition select.o_searchview_extended_prop_field').val(), 'datetime',
+ "datetime field should be selected");
+ $('.o_apply_filter').click();
+
+ assert.strictEqual($('tr.o_data_row').length, 0, "should display 0 records");
+ form.destroy();
+ });
+
+ QUnit.test('onchanges on many2ones trigger when editing record in form view', function (assert) {
+ assert.expect(10);
+
+ this.data.partner.onchanges.user_id = function () { };
+ this.data.user.fields.other_field = { string: "Other Field", type: "char" };
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="user_id"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ archs: {
+ 'user,false,form': '<form string="Users"><field name="other_field"/></form>',
+ },
+ res_id: 1,
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ if (args.method === 'get_formview_id') {
+ return $.when(false);
+ }
+ if (args.method === 'onchange') {
+ assert.strictEqual(args.args[1].user_id, 17,
+ "onchange is triggered with correct user_id");
+ }
+ return this._super(route, args);
+ },
+ });
+
+ // open the many2one in form view and change something
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_external_button').click();
+ $('.modal-body input[name="other_field"]').val('wood').trigger('input');
+
+ // save the modal and make sure an onchange is triggered
+ $('.modal .modal-footer .btn-primary').first().click();
+ assert.verifySteps(['read', 'get_formview_id', 'load_views', 'read', 'write', 'onchange', 'read']);
+
+ // save the main record, and check that no extra rpcs are done (record
+ // is not dirty, only a related record was modified)
+ form.$buttons.find('.o_form_button_save').click();
+ assert.verifySteps(['read', 'get_formview_id', 'load_views', 'read', 'write', 'onchange', 'read']);
+ form.destroy();
+ });
+
+ QUnit.test('many2one fields with option "no_open"', function (assert) {
+ assert.expect(3);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="trululu" options="{&quot;no_open&quot;: True}" />' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ assert.strictEqual(form.$('span.o_field_widget[name=trululu]').length, 1,
+ "should be displayed inside a span (sanity check)");
+ assert.strictEqual(form.$('span.o_form_uri').length, 0, "should not have an anchor");
+
+ form.$buttons.find('.o_form_button_edit').click();
+ assert.strictEqual(form.$('.o_field_widget[name=trululu] .o_external_button').length,
+ 0, "should not have the button to open the record");
+
+ form.destroy();
+ });
+
+ QUnit.test('many2one in edit mode', function (assert) {
+ assert.expect(16);
+
+ // create 10 partners to have the 'Search More' option in the autocomplete dropdown
+ for (var i = 0; i < 10; i++) {
+ var id = 20 + i;
+ this.data.partner.records.push({ id: id, display_name: "Partner " + id });
+ }
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="trululu"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ archs: {
+ 'partner,false,list': '<tree string="Partners"><field name="display_name"/></tree>',
+ 'partner,false,search': '<search string="Partners">' +
+ '<field name="display_name" string="Name"/>' +
+ '</search>',
+ },
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/call_kw/partner/write') {
+ assert.strictEqual(args.args[1].trululu, 20, "should write the correct id");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // the SelectCreateDialog requests the session, so intercept its custom
+ // event to specify a fake session to prevent it from crashing
+ testUtils.intercept(form, 'get_session', function (event) {
+ event.data.callback({ user_context: {} });
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ var $dropdown = form.$('.o_field_many2one input').autocomplete('widget');
+
+ form.$('.o_field_many2one input').click();
+ assert.ok($dropdown.is(':visible'),
+ 'clicking on the m2o input should open the dropdown if it is not open yet');
+ assert.strictEqual($dropdown.find('li:not(.o_m2o_dropdown_option)').length, 7,
+ 'autocomplete should contains 7 suggestions');
+ assert.strictEqual($dropdown.find('li.o_m2o_dropdown_option').length, 2,
+ 'autocomplete should contain "Search More" and Create and Edit..."');
+
+ form.$('.o_field_many2one input').click();
+ assert.ok(!$dropdown.is(':visible'),
+ 'clicking on the m2o input should close the dropdown if it is open');
+
+ // change the value of the m2o with a suggestion of the dropdown
+ form.$('.o_field_many2one input').click();
+ $dropdown.find('li:first()').click();
+ assert.ok(!$dropdown.is(':visible'), 'clicking on a value should close the dropdown');
+ assert.strictEqual(form.$('.o_field_many2one input').val(), 'first record',
+ 'value of the m2o should have been correctly updated');
+
+ // change the value of the m2o with a record in the 'Search More' modal
+ form.$('.o_field_many2one input').click();
+ // click on 'Search More' (mouseenter required by ui-autocomplete)
+ $dropdown.find('.o_m2o_dropdown_option:contains(Search)').mouseenter().click();
+ assert.ok($('.modal .o_list_view').length, "should have opened a list view in a modal");
+ assert.ok(!$('.modal .o_list_view .o_list_record_selector').length,
+ "there should be no record selector in the list view");
+ assert.ok(!$('.modal .modal-footer .o_select_button').length,
+ "there should be no 'Select' button in the footer");
+ assert.ok($('.modal tbody tr').length > 10, "list should contain more than 10 records");
+ // filter the list using the searchview
+ $('.modal .o_searchview_input').trigger({ type: 'keypress', which: 80 }); // P
+ $('.modal .o_searchview_input').trigger({ type: 'keydown', which: 13 }); // enter
+ assert.strictEqual($('.modal tbody tr').length, 10,
+ "list should be restricted to records containing a P (10 records)");
+ // choose a record
+ $('.modal tbody tr:contains(Partner 20)').click(); // choose record 'Partner 20'
+ assert.ok(!$('.modal').length, "should have closed the modal");
+ assert.ok(!$dropdown.is(':visible'), 'should have closed the dropdown');
+ assert.strictEqual(form.$('.o_field_many2one input').val(), 'Partner 20',
+ 'value of the m2o should have been correctly updated');
+
+ // save
+ form.$buttons.find('.o_form_button_save').click();
+ assert.strictEqual(form.$('a.o_form_uri').text(), 'Partner 20',
+ "should display correct value after save");
+
+ form.destroy();
+ });
+
+ QUnit.test('many2one in non edit mode', function (assert) {
+ assert.expect(3);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="trululu"/>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ assert.strictEqual(form.$('a.o_form_uri').length, 1,
+ "should display 1 m2o link in form");
+ assert.strictEqual(form.$('a.o_form_uri').attr('href'), "#id=4&model=partner",
+ "href should contain id and model");
+
+ // Remove value from many2one and then save, there should not have href with id and model on m2o anchor
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_many2one input').val('').trigger('keyup').trigger('focusout');
+ form.$buttons.find('.o_form_button_save').click();
+
+ assert.strictEqual(form.$('a.o_form_uri').attr('href'), "#",
+ "href should have #");
+
+ form.destroy();
+ });
+
+ QUnit.test('many2one searches with correct value', function (assert) {
+ var done = assert.async();
+ assert.expect(6);
+
+ var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="trululu"/>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.method === 'name_search') {
+ assert.step('search: ' + args.kwargs.name);
+ }
+ return this._super.apply(this, arguments);
+ },
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ assert.strictEqual(form.$('.o_field_many2one input').val(), 'aaa',
+ "should be initially set to 'aaa'");
+
+ form.$('.o_field_many2one input').click(); // should search with ''
+ // unset the many2one -> should search again with ''
+ form.$('.o_field_many2one input').val('').trigger('keydown');
+ concurrency.delay(0).then(function () {
+ // write 'p' -> should search with 'p'
+ form.$('.o_field_many2one input').val('p').trigger('keydown').trigger('keyup');
+
+ return concurrency.delay(0);
+ }).then(function () {
+ // close and re-open the dropdown -> should search with 'p' again
+ form.$('.o_field_many2one input').click();
+ form.$('.o_field_many2one input').click();
+
+ assert.verifySteps(['search: ', 'search: ', 'search: p', 'search: p']);
+
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
+ form.destroy();
+ done();
+ });
+ });
+
+ QUnit.test('many2one field with option always_reload', function (assert) {
+ assert.expect(4);
+ var count = 0;
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="trululu" options="{\'always_reload\': True}"/>' +
+ '</form>',
+ res_id: 2,
+ mockRPC: function (route, args) {
+ if (args.method === 'name_get') {
+ count++;
+ return $.when([[1, "first record\nand some address"]]);
+ }
+ return this._super(route, args);
+ },
+ });
+
+ assert.strictEqual(count, 1, "an extra name_get should have been done");
+ assert.ok(form.$('a:contains(and some address)').length,
+ "should display additional result");
+
+ form.$buttons.find('.o_form_button_edit').click();
+
+ assert.strictEqual(form.$('input').val(), "first record",
+ "actual field value should be displayed to be edited");
+
+ form.$buttons.find('.o_form_button_save').click();
+
+ assert.ok(form.$('a:contains(and some address)').length,
+ "should still display additional result");
+ form.destroy();
+ });
+
+ QUnit.test('many2one field and list navigation', function (assert) {
+ assert.expect(3);
+
+ var list = createView({
+ View: ListView,
+ model: 'partner',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="trululu"/></tree>',
+ });
+
+ // edit first input, to trigger autocomplete
+ list.$('.o_data_row .o_data_cell').first().click();
+ list.$('.o_data_cell input').val('').trigger('input');
+
+ // press keydown, to select first choice
+ list.$('.o_data_cell input').focus().trigger($.Event('keydown', {
+ which: $.ui.keyCode.DOWN,
+ keyCode: $.ui.keyCode.DOWN,
+ }));
+
+ // we now check that the dropdown is open (and that the focus did not go
+ // to the next line)
+ var $dropdown = list.$('.o_field_many2one input').autocomplete('widget');
+ assert.ok($dropdown.is(':visible'), "dropdown should be visible");
+ assert.ok(list.$('.o_data_row:eq(0)').hasClass('o_selected_row'),
+ 'first data row should still be selected');
+ assert.ok(!list.$('.o_data_row:eq(1)').hasClass('o_selected_row'),
+ 'second data row should not be selected');
+
+ list.destroy();
+ });
+
+ QUnit.test('standalone many2one field', function (assert) {
+ assert.expect(4);
+ var done = assert.async();
+
+ var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
+
+ var fixture = $('#qunit-fixture');
+ var self = this;
+
+ var model = testUtils.createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+
+ model.makeRecord('coucou', [{
+ name: 'partner_id',
+ relation: 'partner',
+ type: 'many2one',
+ value: [1, 'first partner'],
+ }]).then(function (recordID) {
+ var record = model.get(recordID);
+ // create a new widget that uses the StandaloneFieldManagerMixin
+ var StandaloneWidget = Widget.extend(StandaloneFieldManagerMixin, {
+ init: function (parent) {
+ this._super.apply(this, arguments);
+ StandaloneFieldManagerMixin.init.call(this, parent);
+ },
+ });
+ var parent = new StandaloneWidget(model);
+ testUtils.addMockEnvironment(parent, {
+ data: self.data,
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ var relField = new relationalFields.FieldMany2One(parent, 'partner_id', record, {
+ mode: 'edit',
+ noOpen: true,
+ });
+
+ relField.appendTo(fixture);
+ $('input.o_input').val('xyzzrot').trigger('input');
+
+ concurrency.delay(0).then(function () {
+ var $dropdown = $('input.o_input').autocomplete('widget');
+ $dropdown.find('.o_m2o_dropdown_option:contains(Create)')
+ .first().mouseenter().click();
+
+ assert.strictEqual(relField.$('.o_external_button').length, 0,
+ "should not have the button to open the record");
+ assert.verifySteps(['name_search', 'name_create']);
+
+ parent.destroy();
+ model.destroy();
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
+ done();
+ });
+ });
+ });
+
+ // QUnit.test('onchange on a many2one to a different model', function (assert) {
+ // This test is commented because the mock server does not give the correct response.
+ // It should return a couple [id, display_name], but I don't know the logic used
+ // by the server, so it's hard to emulate it correctly
+ // assert.expect(2);
+
+ // this.data.partner.records[0].product_id = 41;
+ // this.data.partner.onchanges = {
+ // foo: function(obj) {
+ // obj.product_id = 37;
+ // },
+ // };
+
+ // var form = createView({
+ // View: FormView,
+ // model: 'partner',
+ // data: this.data,
+ // arch: '<form>' +
+ // '<field name="foo"/>' +
+ // '<field name="product_id"/>' +
+ // '</form>',
+ // res_id: 1,
+ // });
+ // form.$buttons.find('.o_form_button_edit').click();
+ // assert.strictEqual(form.$('input').eq(1).val(), 'xpad', "initial product_id val should be xpad");
+
+ // form.$('input').eq(0).val("let us trigger an onchange").trigger('input');
+
+ // assert.strictEqual(form.$('input').eq(1).val(), 'xphone', "onchange should have been applied");
+ // });
+
+ QUnit.test('form: quick create then save directly', function (assert) {
+ var done = assert.async();
+ assert.expect(5);
+
+ var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
+
+ var def = $.Deferred();
+ var newRecordID;
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="trululu"/>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'name_create') {
+ assert.step('name_create');
+ return def.then(_.constant(result)).then(function (nameGet) {
+ newRecordID = nameGet[0];
+ return nameGet;
+ });
+ }
+ if (args.method === 'create') {
+ assert.step('create');
+ assert.strictEqual(args.args[0].trululu, newRecordID,
+ "should create with the correct m2o id");
+ }
+ return result;
+ },
+ });
+
+ var $dropdown = form.$('.o_field_many2one input').autocomplete('widget');
+ form.$('.o_field_many2one input').val('b').trigger('keydown');
+ concurrency.delay(0).then(function () {
+ $dropdown.find('li:first()').click(); // quick create 'b'
+ form.$buttons.find('.o_form_button_save').click();
+
+ assert.verifySteps(['name_create'],
+ "should wait for the name_create before creating the record");
+
+ def.resolve();
+
+ assert.verifySteps(['name_create', 'create']);
+
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
+ form.destroy();
+ done();
+ });
+ });
+
+ QUnit.test('list: quick create then save directly', function (assert) {
+ var done = assert.async();
+ assert.expect(8);
+
+ var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
+
+ var def = $.Deferred();
+ var newRecordID;
+ var list = createView({
+ View: ListView,
+ model: 'partner',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="trululu"/>' +
+ '</tree>',
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'name_create') {
+ assert.step('name_create');
+ return def.then(_.constant(result)).then(function (nameGet) {
+ newRecordID = nameGet[0];
+ return nameGet;
+ });
+ }
+ if (args.method === 'create') {
+ assert.step('create');
+ assert.strictEqual(args.args[0].trululu, newRecordID,
+ "should create with the correct m2o id");
+ }
+ return result;
+ },
+ });
+
+ list.$buttons.find('.o_list_button_add').click();
+
+ var $dropdown = list.$('.o_field_many2one input').autocomplete('widget');
+ list.$('.o_field_many2one input').val('b').trigger('keydown');
+ concurrency.delay(0).then(function () {
+ $dropdown.find('li:first()').click(); // quick create 'b'
+ list.$buttons.find('.o_list_button_add').click();
+
+ assert.verifySteps(['name_create'],
+ "should wait for the name_create before creating the record");
+ assert.strictEqual(list.$('.o_data_row').length, 4,
+ "should wait for the name_create before adding the new row");
+
+ def.resolve();
+
+ assert.verifySteps(['name_create', 'create']);
+ assert.strictEqual(list.$('.o_data_row:nth(1) .o_data_cell').text(), 'b',
+ "created row should have the correct m2o value");
+ assert.strictEqual(list.$('.o_data_row').length, 5,
+ "should have added the fifth row");
+
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
+ list.destroy();
+ done();
+ });
+ });
+
+ QUnit.test('list in form: quick create then save directly', function (assert) {
+ var done = assert.async();
+ assert.expect(6);
+
+ var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
+
+ var def = $.Deferred();
+ var newRecordID;
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="p">' +
+ '<tree editable="bottom">' +
+ '<field name="trululu"/>' +
+ '</tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'name_create') {
+ assert.step('name_create');
+ return def.then(_.constant(result)).then(function (nameGet) {
+ newRecordID = nameGet[0];
+ return nameGet;
+ });
+ }
+ if (args.method === 'create') {
+ assert.step('create');
+ assert.strictEqual(args.args[0].p[0][2].trululu, newRecordID,
+ "should create with the correct m2o id");
+ }
+ return result;
+ },
+ });
+
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ var $dropdown = form.$('.o_field_many2one input').autocomplete('widget');
+ form.$('.o_field_many2one input').val('b').trigger('keydown');
+ concurrency.delay(0).then(function () {
+ $dropdown.find('li:first()').click(); // quick create 'b'
+ form.$buttons.find('.o_form_button_save').click();
+
+ assert.verifySteps(['name_create'],
+ "should wait for the name_create before creating the record");
+
+ def.resolve();
+
+ assert.verifySteps(['name_create', 'create']);
+ assert.strictEqual(form.$('.o_data_row:first .o_data_cell').text(), 'b',
+ "first row should have the correct m2o value");
+
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
+ form.destroy();
+ done();
+ });
+ });
+
+ QUnit.test('list in form: quick create then add a new line directly', function (assert) {
+ // required many2one inside a one2many list: directly after quick creating
+ // a new many2one value (before the name_create returns), click on add an item:
+ // at this moment, the many2one has still no value, and as it is required,
+ // the row is discarded if a saveLine is requested. However, it should
+ // wait for the name_create to return before trying to save the line.
+ var done = assert.async();
+ assert.expect(8);
+
+ this.data.partner.onchanges = {
+ trululu: function () { },
+ };
+
+ var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
+
+ var def = $.Deferred();
+ var newRecordID;
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="p">' +
+ '<tree editable="bottom">' +
+ '<field name="trululu" required="1"/>' +
+ '</tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'name_create') {
+ return def.then(_.constant(result)).then(function (nameGet) {
+ newRecordID = nameGet[0];
+ return nameGet;
+ });
+ }
+ if (args.method === 'create') {
+ assert.deepEqual(args.args[0].p[0][2].trululu, newRecordID);
+ }
+ return result;
+ },
+ });
+
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ var $dropdown = form.$('.o_field_many2one input').autocomplete('widget');
+ form.$('.o_field_many2one input').val('b').trigger('keydown');
+ concurrency.delay(0).then(function () {
+ $dropdown.find('li:first()').click(); // quick create 'b'
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "there should still be only one row");
+ assert.ok(form.$('.o_data_row').hasClass('o_selected_row'),
+ "the row should still be in edition");
+
+ def.resolve();
+
+ assert.strictEqual(form.$('.o_data_row:first .o_data_cell').text(), 'b',
+ "first row should have the correct m2o value");
+ assert.strictEqual(form.$('.o_data_row').length, 2,
+ "there should now be 2 rows");
+ assert.ok(form.$('.o_data_row:nth(1)').hasClass('o_selected_row'),
+ "the second row should be in edition");
+
+ form.$buttons.find('.o_form_button_save').click();
+
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "there should be 1 row saved (the second one was empty and invalid)");
+ assert.strictEqual(form.$('.o_data_row .o_data_cell').text(), 'b',
+ "should have the correct m2o value");
+
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
+ form.destroy();
+ done();
+ });
+ });
+
+ QUnit.test('list in form: create with one2many with many2one', function (assert) {
+ assert.expect(1);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="p">' +
+ '<tree editable="bottom">' +
+ '<field name="display_name"/>' +
+ '<field name="trululu"/>' +
+ '</tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'default_get') {
+ return $.when({ p: [[0, 0, { display_name: 'new record' }]] });
+ } else if (args.method === 'name_get') {
+ // This should not be called at all and thus is not accounted for
+ // in the assert.expect. If this is called, you broke this test.
+ assert.notOk(_.str.startsWith(args.args[0][0], 'virtual_'),
+ "should not call name_get for the m2o inside o2m which has no value");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual($('td.o_data_cell:first').text(), 'new record',
+ "should have created the new record in the o2m with the correct name");
+
+ form.destroy();
+ });
+
+ QUnit.test('list in form: create with one2many with many2one (version 2)', function (assert) {
+ // This test simulates the exact same scenario as the previous one,
+ // except that the value for the many2one is explicitely set to false,
+ // which is stupid, but this happens, so we have to handle it
+ assert.expect(1);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="p">' +
+ '<tree editable="bottom">' +
+ '<field name="display_name"/>' +
+ '<field name="trululu"/>' +
+ '</tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'default_get') {
+ return $.when({ p: [[0, 0, { display_name: 'new record', trululu: false }]] });
+ } else if (args.method === 'name_get') {
+ // This should not be called at all and thus is not accounted for
+ // in the assert.expect. If this is called, you broke this test.
+ assert.notOk(_.str.startsWith(args.args[0][0], 'virtual_'),
+ "should not call name_get for the m2o inside o2m which has no value");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual($('td.o_data_cell:first').text(), 'new record',
+ "should have created the new record in the o2m with the correct name");
+
+ form.destroy();
+ });
+
+ QUnit.test('item not dropped on discard with empty required field (default_get)', function (assert) {
+ // This test simulates discarding a record that has been created with
+ // one of its required field that is empty. When we discard the changes
+ // on this empty field, it should not assume that this record should be
+ // abandonned, since it has been added (even though it is a new record).
+ assert.expect(8);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="p">' +
+ '<tree editable="bottom">' +
+ '<field name="display_name"/>' +
+ '<field name="trululu" required="1"/>' +
+ '</tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'default_get') {
+ return $.when({ p: [[0, 0, { display_name: 'new record', trululu: false }]] });
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual($('tr.o_data_row').length, 1,
+ "should have created the new record in the o2m");
+ assert.strictEqual($('td.o_data_cell').first().text(), "new record",
+ "should have the correct displayed name");
+
+ var requiredElement = $('td.o_data_cell.o_required_modifier');
+ assert.strictEqual(requiredElement.length, 1,
+ "should have a required field on this record");
+ assert.strictEqual(requiredElement.text(), "",
+ "should have empty string in the required field on this record");
+
+ requiredElement.click();
+ // discard by clicking on body
+ $('body').click();
+
+ assert.strictEqual($('tr.o_data_row').length, 1,
+ "should still have the record in the o2m");
+ assert.strictEqual($('td.o_data_cell').first().text(), "new record",
+ "should still have the correct displayed name");
+
+ // update selector of required field element
+ requiredElement = $('td.o_data_cell.o_required_modifier');
+ assert.strictEqual(requiredElement.length, 1,
+ "should still have the required field on this record");
+ assert.strictEqual(requiredElement.text(), "",
+ "should still have empty string in the required field on this record");
+ form.destroy();
+ });
+
+ QUnit.test('list in form: name_get with unique ids (default_get)', function (assert) {
+ assert.expect(2);
+
+ this.data.partner.records[0].display_name = "MyTrululu";
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="p">' +
+ '<tree editable="bottom">' +
+ '<field name="trululu"/>' +
+ '</tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'default_get') {
+ return $.when({
+ p: [
+ [0, 0, { trululu: 1 }],
+ [0, 0, { trululu: 1 }]
+ ]
+ });
+ }
+ if (args.method === 'name_get') {
+ assert.deepEqual(args.args[0], _.uniq(args.args[0]),
+ "should not have duplicates in name_get rpc");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual(form.$('td.o_data_cell').text(), "MyTrululuMyTrululu",
+ "both records should have the correct display_name for trululu field");
+
+ form.destroy();
+ });
+
+ QUnit.test('list in form: show name of many2one fields in multi-page (default_get)', function (assert) {
+ assert.expect(4);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="p">' +
+ '<tree editable="bottom" limit="1">' +
+ '<field name="display_name"/>' +
+ '<field name="trululu"/>' +
+ '</tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'default_get') {
+ return $.when({
+ p: [
+ [0, 0, { display_name: 'record1', trululu: 1 }],
+ [0, 0, { display_name: 'record2', trululu: 2 }]
+ ]
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual(form.$('td.o_data_cell').first().text(),
+ "record1", "should show display_name of 1st record");
+ assert.strictEqual(form.$('td.o_data_cell').first().next().text(),
+ "first record", "should show display_name of trululu of 1st record");
+
+ form.$('button.o_pager_next').click();
+
+ assert.strictEqual(form.$('td.o_data_cell').first().text(),
+ "record2", "should show display_name of 2nd record");
+ assert.strictEqual(form.$('td.o_data_cell').first().next().text(),
+ "second record", "should show display_name of trululu of 2nd record");
+
+ form.destroy();
+ });
+
+ QUnit.test('list in form: item not dropped on discard with empty required field (onchange in default_get)', function (assert) {
+ // variant of the test "list in form: discard newly added element with
+ // empty required field (default_get)", in which the `default_get`
+ // performs an `onchange` at the same time. This `onchange` may create
+ // some records, which should not be abandoned on discard, similarly
+ // to records created directly by `default_get`
+ assert.expect(7);
+
+ var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
+
+ this.data.partner.onchanges = {
+ product_id: function (obj) {
+ if (obj.product_id === 37) {
+ obj.p = [[0, 0, { display_name: "entry", trululu: false }]];
+ }
+ },
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="product_id"/>' +
+ '<field name="p">' +
+ '<tree editable="bottom">' +
+ '<field name="display_name"/>' +
+ '<field name="trululu" required="1"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'default_get') {
+ return $.when({
+ product_id: 37,
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // check that there is a record in the editable list with empty string as required field
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "should have a row in the editable list");
+ assert.strictEqual($('td.o_data_cell').first().text(), "entry",
+ "should have the correct displayed name");
+ var requiredField = $('td.o_data_cell.o_required_modifier');
+ assert.strictEqual(requiredField.length, 1,
+ "should have a required field on this record");
+ assert.strictEqual(requiredField.text(), "",
+ "should have empty string in the required field on this record");
+
+ // click on empty required field in editable list record
+ requiredField.click();
+ // click off so that the required field still stay empty
+ $('body').click();
+
+ // record should not be dropped
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "should not have dropped record in the editable list");
+ assert.strictEqual($('td.o_data_cell').first().text(), "entry",
+ "should still have the correct displayed name");
+ assert.strictEqual($('td.o_data_cell.o_required_modifier').text(), "",
+ "should still have empty string in the required field");
+
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
+ form.destroy();
+ });
+
+ QUnit.test('list in form: item not dropped on discard with empty required field (onchange on list after default_get)', function (assert) {
+ // discarding a record from an `onchange` in a `default_get` should not
+ // abandon the record. This should not be the case for following
+ // `onchange`, except if an onchange make some changes on the list:
+ // in particular, if an onchange make changes on the list such that
+ // a record is added, this record should not be dropped on discard
+ assert.expect(8);
+
+ var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
+
+ this.data.partner.onchanges = {
+ product_id: function (obj) {
+ if (obj.product_id === 37) {
+ obj.p = [[0, 0, { display_name: "entry", trululu: false }]];
+ }
+ },
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="product_id"/>' +
+ '<field name="p">' +
+ '<tree editable="bottom">' +
+ '<field name="display_name"/>' +
+ '<field name="trululu" required="1"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ });
+
+ // check no record in list
+ assert.strictEqual(form.$('.o_data_row').length, 0,
+ "should have no row in the editable list");
+
+ // select product_id to force on_change in editable list
+ form.$('.o_field_widget[name="product_id"] .o_input').click();
+ $('.ui-menu-item').first().click();
+
+ // check that there is a record in the editable list with empty string as required field
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "should have a row in the editable list");
+ assert.strictEqual($('td.o_data_cell').first().text(), "entry",
+ "should have the correct displayed name");
+ var requiredField = $('td.o_data_cell.o_required_modifier');
+ assert.strictEqual(requiredField.length, 1,
+ "should have a required field on this record");
+ assert.strictEqual(requiredField.text(), "",
+ "should have empty string in the required field on this record");
+
+ // click on empty required field in editable list record
+ requiredField.click();
+ // click off so that the required field still stay empty
+ $('body').click();
+
+ // record should not be dropped
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "should not have dropped record in the editable list");
+ assert.strictEqual($('td.o_data_cell').first().text(), "entry",
+ "should still have the correct displayed name");
+ assert.strictEqual($('td.o_data_cell.o_required_modifier').text(), "",
+ "should still have empty string in the required field");
+
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
+ form.destroy();
+ });
+
+ QUnit.test('item dropped on discard with empty required field with "Add an item" (invalid on "ADD")', function (assert) {
+ // when a record in a list is added with "Add an item", it should
+ // always be dropped on discard if some required field are empty
+ // at the record creation.
+ assert.expect(6);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="p">' +
+ '<tree editable="bottom">' +
+ '<field name="display_name"/>' +
+ '<field name="trululu" required="1"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ });
+
+ // Click on "Add an item"
+ form.$('.o_field_x2many_list_row_add a').click();
+ var charField = form.$('.o_field_widget.o_field_char[name="display_name"]');
+ var requiredField = form.$('.o_field_widget.o_required_modifier[name="trululu"]');
+ charField.val("some text");
+ assert.strictEqual(charField.length, 1,
+ "should have a char field 'display_name' on this record");
+ assert.notOk(charField.hasClass('o_required_modifier'),
+ "the char field should not be required on this record");
+ assert.strictEqual(charField.val(), "some text",
+ "should have entered text in the char field on this record");
+ assert.strictEqual(requiredField.length, 1,
+ "should have a required field 'trululu' on this record");
+ assert.strictEqual(requiredField.val().trim(), "",
+ "should have empty string in the required field on this record");
+
+ // click on empty required field in editable list record
+ requiredField.click();
+ // click off so that the required field still stay empty
+ $('body').click();
+
+ // record should be dropped
+ assert.strictEqual(form.$('.o_data_row').length, 0,
+ "should have dropped record in the editable list");
+
+ form.destroy();
+ });
+
+ QUnit.test('item not dropped on discard with empty required field with "Add an item" (invalid on "UPDATE")', function (assert) {
+ // when a record in a list is added with "Add an item", it should
+ // be temporarily added to the list when it is valid (e.g. required
+ // fields are non-empty). If the record is updated so that the required
+ // field is empty, and it is discarded, then the record should not be
+ // dropped.
+ assert.expect(8);
+
+ var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="p">' +
+ '<tree editable="bottom">' +
+ '<field name="display_name"/>' +
+ '<field name="trululu" required="1"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ });
+
+ assert.strictEqual(form.$('.o_data_row').length, 0,
+ "should initially not have any record in the list");
+
+ // Click on "Add an item"
+ form.$('.o_field_x2many_list_row_add a').click();
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "should have a temporary record in the list");
+
+ var $inputEditMode = form.$('.o_field_widget.o_required_modifier[name="trululu"] input');
+ assert.strictEqual($inputEditMode.length, 1,
+ "should have a required field 'trululu' on this record");
+ assert.strictEqual($inputEditMode.val(), "",
+ "should have empty string in the required field on this record");
+
+ // add something to required field and leave edit mode of the record
+ $inputEditMode.click();
+ $('li.ui-menu-item').first().click();
+ $('body').click(); // leave edit mode on the line
+
+ var $inputReadonlyMode = form.$('.o_data_cell.o_required_modifier');
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "should not have dropped valid record when leaving edit mode");
+ assert.strictEqual($inputReadonlyMode.text(), "first record",
+ "should have put some content in the required field on this record");
+
+ // remove the required field and leave edit mode of the record
+ $('.o_data_row').click(); // enter edit mode on the line
+ $inputEditMode.click();
+ $inputEditMode.val("");
+ $('body').click();
+
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "should not have dropped record in the list on discard (invalid on UPDATE)");
+ assert.strictEqual($inputReadonlyMode.text(), "first record",
+ "should keep previous valid required field content on this record");
+
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
+ form.destroy();
+ });
+
+ QUnit.test('list in form: default_get with x2many create', function (assert) {
+ assert.expect(5);
+
+ var displayName = 'brandon is the new timmy';
+ this.data.partner.onchanges.timmy = function (obj) {
+ assert.deepEqual(
+ obj.timmy,
+ [
+ [6, false, []],
+ [0, obj.timmy[1][1], { display_name: displayName, name: 'brandon' }]
+ ],
+ "should have properly created the x2many command list");
+ obj.int_field = obj.timmy.length;
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="timmy">' +
+ '<tree editable="bottom">' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '</field>' +
+ '<field name="int_field"/>' +
+ '</sheet>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'default_get') {
+ return $.when({ timmy: [[0, 0, { display_name: 'brandon is the new timmy', name: 'brandon' }]] });
+ }
+ if (args.method === 'create') {
+ assert.deepEqual(args.args[0], {
+ int_field: 2,
+ timmy: [
+ [6, false, []],
+ [0, args.args[0].timmy[1][1], { display_name: displayName, name: 'brandon' }],
+ ],
+ }, "should send the correct values to create");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual($('td.o_data_cell:first').text(), 'brandon is the new timmy',
+ "should have created the new record in the m2m with the correct name");
+ assert.strictEqual($('input.o_field_integer').val(), '2',
+ "should have called and executed the onchange properly");
+
+ // edit the subrecord and save
+ displayName = 'new value';
+ form.$('.o_data_cell').click();
+ form.$('.o_data_cell input').val(displayName).trigger('input');
+ form.$buttons.find('.o_form_button_save').click();
+
+ form.destroy();
+ });
+
+ QUnit.test('list in form: default_get with x2many create and onchange', function (assert) {
+ assert.expect(2);
+
+ this.data.partner.onchanges.turtles = function (obj) {
+ assert.deepEqual(
+ obj.turtles,
+ [
+ [4, 2, false],
+ [1, 2, { turtle_foo: 'blip' }],
+ [4, 3, false],
+ [1, 3, { turtle_foo: 'kawa' }]
+ ],
+ "should have properly created the x2many command list");
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="turtles">' +
+ '<tree editable="bottom">' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '<field name="int_field"/>' +
+ '</sheet>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'default_get') {
+ return $.when({ turtles: [[6, 0, [2, 3]]] });
+ }
+ if (args.method === 'create') {
+ // it would be even better if we did not send the current
+ // unchanged state with the command 1, but this seems more
+ // difficult.
+ assert.deepEqual(args.args[0].turtles, [
+ [4, 2, false],
+ [1, 2, { turtle_foo: 'blip' }],
+ [4, 3, false],
+ [1, 3, { turtle_foo: 'kawa' }]
+ ], 'should send proper commands to create method');
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ form.$buttons.find('.o_form_button_save').click();
+
+ form.destroy();
+ });
+
+ QUnit.test('list in form: call button in sub view', function (assert) {
+ assert.expect(6);
+
+ this.data.partner.records[0].p = [2];
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="p">' +
+ '<tree editable="bottom">' +
+ '<field name="product_id"/>' +
+ '</tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/call_kw/product/get_formview_id') {
+ return $.when(false);
+ }
+ return this._super.apply(this, arguments);
+ },
+ intercepts: {
+ execute_action: function (event) {
+ assert.strictEqual(event.data.env.model, 'product',
+ 'should call with correct model in env');
+ assert.strictEqual(event.data.env.currentID, 37,
+ 'should call with correct currentID in env');
+ assert.deepEqual(event.data.env.resIDs, [37],
+ 'should call with correct resIDs in env');
+ },
+ },
+ archs: {
+ 'product,false,form': '<form string="Partners">' +
+ '<header>' +
+ '<button name="action" type="action" string="Just do it !"/>' +
+ '<button name="object" type="object" string="Just don\'t do it !"/>' +
+ '<field name="display_name"/>' +
+ '</header>' +
+ '</form>',
+ },
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('td.o_data_cell:first').click(); // edit first one2many line
+ form.$('.o_external_button').click(); // open product sub view in modal
+ $('button:contains("Just do it !")').click(); // click on action button
+ $('button:contains("Just don\'t do it !")').click(); // click on object button
+
+ form.destroy();
+ });
+
+ QUnit.test('X2Many sequence list in modal', function (assert) {
+ assert.expect(5);
+
+ this.data.partner.fields.sequence = { string: 'Sequence', type: 'integer' };
+ this.data.partner.records[0].sequence = 1;
+ this.data.partner.records[1].sequence = 2;
+ this.data.partner.onchanges = {
+ sequence: function (obj) {
+ if (obj.id === 2) {
+ obj.sequence = 1;
+ assert.step('onchange sequence');
+ }
+ },
+ };
+
+ this.data.product.fields.turtle_ids = { string: 'Turtles', type: 'one2many', relation: 'turtle' };
+ this.data.product.records[0].turtle_ids = [1];
+
+ this.data.turtle.fields.partner_types_ids = { string: "Partner", type: "one2many", relation: 'partner' };
+ this.data.turtle.fields.type_id = { string: "Partner Type", type: "many2one", relation: 'partner_type' };
+
+ this.data.partner_type.fields.partner_ids = { string: "Partner", type: "one2many", relation: 'partner' };
+ this.data.partner_type.records[0].partner_ids = [1, 2];
+
+ testUtils.createAsyncView({
+ View: FormView,
+ model: 'product',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="name"/>' +
+ '<field name="turtle_ids" widget="one2many_list">' +
+ '<tree string="Turtles" editable="bottom">' +
+ '<field name="type_id"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ archs: {
+ 'partner_type,false,form': '<form><field name="partner_ids"/></form>',
+ 'partner,false,list': '<tree string="Vendors">' +
+ '<field name="display_name"/>' +
+ '<field name="sequence" widget="handle"/>' +
+ '</tree>',
+ },
+ res_id: 37,
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/call_kw/product/read') {
+ return $.when([{ id: 37, name: 'xphone', display_name: 'leonardo', turtle_ids: [1] }]);
+ }
+ if (route === '/web/dataset/call_kw/turtle/read') {
+ return $.when([{ id: 1, type_id: [12, 'gold'] }]);
+ }
+ if (route === '/web/dataset/call_kw/partner_type/get_formview_id') {
+ return $.when(false);
+ }
+ if (route === '/web/dataset/call_kw/partner_type/read') {
+ return $.when([{ id: 12, partner_ids: [1, 2], display_name: 'gold' }]);
+ }
+ if (route === '/web/dataset/call_kw/partner_type/write') {
+ assert.step('partner_type write');
+ }
+ return this._super.apply(this, arguments);
+ },
+ }).then(function (form) {
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_data_cell').click();
+ form.$('.o_external_button').click();
+
+ var $modal = $('.modal');
+ assert.equal($modal.length, 1,
+ 'There should be 1 modal opened');
+
+ var $handles = $modal.find('.ui-sortable-handle');
+ assert.equal($handles.length, 2,
+ 'There should be 2 sequence handlers');
+
+ testUtils.dragAndDrop($handles.eq(1), $modal.find('tbody tr').first(),
+ { position: 'top' });
+
+ // Saving the modal and then the original model
+ $modal.find('.modal-footer .btn-primary').click();
+ form.$buttons.find('.o_form_button_save').click();
+
+ assert.verifySteps(['onchange sequence', 'partner_type write']);
+
+ form.destroy();
+ });
+ });
+
+ QUnit.test('autocompletion in a many2one, in form view with a domain', function (assert) {
+ assert.expect(1);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="product_id"/>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: {
+ domain: [['trululu', '=', 4]]
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'name_search') {
+ assert.deepEqual(args.kwargs.args, [], "should not have a domain");
+ }
+ return this._super(route, args);
+ }
+ });
+ form.$buttons.find('.o_form_button_edit').click();
+
+ form.$('input').click();
+ form.destroy();
+ });
+
+ QUnit.test('autocompletion in a many2one, in form view with a date field', function (assert) {
+ assert.expect(1);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="bar"/>' +
+ '<field name="date"/>' +
+ '<field name="trululu" domain="[(\'bar\',\'=\',True)]"/>' +
+ '</form>',
+ res_id: 2,
+ mockRPC: function (route, args) {
+ if (args.method === 'name_search') {
+ assert.deepEqual(args.kwargs.args, [["bar", "=", true]], "should not have a domain");
+ }
+ return this._super(route, args);
+ },
+ });
+ form.$buttons.find('.o_form_button_edit').click();
+
+ form.$('input:eq(2)').click();
+ form.destroy();
+ });
+
+ QUnit.test('creating record with many2one with option always_reload', function (assert) {
+ assert.expect(2);
+
+ this.data.partner.fields.trululu.default = 1;
+ this.data.partner.onchanges = {
+ trululu: function (obj) {
+ obj.trululu = 2; //[2, "second record"];
+ },
+ };
+
+ var count = 0;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="trululu" options="{\'always_reload\': True}"/>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ count++;
+ if (args.method === 'name_get' && args.args[0][0] === 2) {
+ return $.when([[2, "hello world\nso much noise"]]);
+ }
+ return this._super(route, args);
+ },
+ });
+
+ assert.strictEqual(count, 3, "should have done 3 rpcs (default_get, onchange, name_get)");
+ assert.strictEqual(form.$('input').val(), 'hello world',
+ "should have taken the correct display name");
+ form.destroy();
+ });
+
+ QUnit.test('selecting a many2one, then discarding', function (assert) {
+ assert.expect(3);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="product_id"/>' +
+ '</form>',
+ res_id: 1,
+ });
+ assert.strictEqual(form.$('a').text(), '', 'the tag a should be empty');
+ form.$buttons.find('.o_form_button_edit').click();
+
+ form.$('.o_field_many2one input').click();
+ form.$('.o_field_many2one input').autocomplete('widget').find('a').first().click();
+
+
+ assert.strictEqual(form.$('input').val(), "xphone", "should have selected xphone");
+
+ form.$buttons.find('.o_form_button_cancel').click();
+ assert.strictEqual(form.$('a').text(), '', 'the tag a should be empty');
+ form.destroy();
+ });
+
+ QUnit.test('domain and context are correctly used when doing a name_search in a m2o', function (assert) {
+ assert.expect(4);
+
+ this.data.partner.records[0].timmy = [12];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:
+ '<form string="Partners">' +
+ '<field name="product_id" ' +
+ 'domain="[[\'foo\', \'=\', \'bar\'], [\'foo\', \'=\', foo]]" ' +
+ 'context="{\'hello\': \'world\', \'test\': foo}"/>' +
+ '<field name="foo"/>' +
+ '<field name="trululu" context="{\'timmy\': timmy}" domain="[[\'id\', \'in\', timmy]]"/>' +
+ '<field name="timmy" widget="many2many_tags" invisible="1"/>' +
+ '</form>',
+ res_id: 1,
+ session: { user_context: { hey: "ho" } },
+ mockRPC: function (route, args) {
+ if (args.method === 'name_search' && args.model === 'product') {
+ assert.deepEqual(
+ args.kwargs.args,
+ [['foo', '=', 'bar'], ['foo', '=', 'yop']],
+ 'the field attr domain should have been used for the RPC (and evaluated)');
+ assert.deepEqual(
+ args.kwargs.context,
+ { hey: "ho", hello: "world", test: "yop" },
+ 'the field attr context should have been used for the ' +
+ 'RPC (evaluated and merged with the session one)');
+ return $.when([]);
+ }
+ if (args.method === 'name_search' && args.model === 'partner') {
+ assert.deepEqual(args.kwargs.args, [['id', 'in', [12]]],
+ 'the field attr domain should have been used for the RPC (and evaluated)');
+ assert.deepEqual(args.kwargs.context, { hey: 'ho', timmy: [[6, false, [12]]] },
+ 'the field attr context should have been used for the RPC (and evaluated)');
+ return $.when([]);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_widget[name=product_id] input').click();
+
+ form.$('.o_field_widget[name=trululu] input').click();
+
+ form.destroy();
+ });
+
+ QUnit.test('quick create on a many2one', function (assert) {
+ assert.expect(1);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="product_id"/>' +
+ '</sheet>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/call_kw/product/name_create') {
+ assert.strictEqual(args.args[0], 'new partner',
+ "should name create a new product");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ form.$('.o_field_many2one input').focus();
+ form.$('.o_field_many2one input').val('new partner').trigger('keyup').trigger('focusout');
+
+ $('.modal .modal-footer .btn-primary').first().click();
+
+ form.destroy();
+ });
+
+ QUnit.test('slow create on a many2one', function (assert) {
+ assert.expect(7);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:
+ '<form>' +
+ '<sheet>' +
+ '<field name="product_id" options="{\'quick_create\': False}"/>' +
+ '</sheet>' +
+ '</form>',
+ archs: {
+ 'product,false,form':
+ '<form>' +
+ '<field name="name"/>' +
+ '</form>',
+ },
+ });
+
+ // cancel the many2one creation with Cancel button
+ form.$('.o_field_many2one input').focus().val('new product').trigger('keyup').trigger('blur');
+ assert.strictEqual($('.modal').length, 1, "there should be one opened modal");
+ $('.modal .modal-footer .btn:contains(Cancel)').click();
+ assert.strictEqual(form.$('.o_field_many2one input').val(), "",
+ 'the many2one should not set a value as its creation has been cancelled (with Cancel button)');
+
+ // cancel the many2one creation with Close button
+ form.$('.o_field_many2one input').focus().val('new product').trigger('keyup').trigger('blur');
+ $('.modal .modal-header button').click();
+ assert.strictEqual(form.$('.o_field_many2one input').val(), "",
+ 'the many2one should not set a value as its creation has been cancelled (with Close button)');
+
+ // select a new value then cancel the creation of the new one --> restore the previous
+ form.$('.o_field_many2one input').click();
+ form.$('.o_field_many2one input').autocomplete('widget').find('a').first().click();
+ assert.strictEqual(form.$('input').val(), "xphone", "should have selected xphone");
+ form.$('.o_field_many2one input').focus().val('new product').trigger('keyup').trigger('blur');
+ assert.strictEqual($('.modal').length, 1, "there should be one opened modal");
+ $('.modal .modal-footer .btn:contains(Cancel)').click();
+ assert.strictEqual(form.$('.o_field_many2one input').val(), "xphone",
+ 'should have restored the many2one with its previous selected value (xphone)');
+
+ // confirm the many2one creation
+ form.$('.o_field_many2one input').focus();
+ form.$('.o_field_many2one input').val('new partner').trigger('keyup').trigger('blur');
+ $('.modal .modal-footer .btn-primary').click();
+ assert.strictEqual($('.modal .o_form_view').length, 1,
+ 'a new modal should be opened and contain a form view');
+
+ form.destroy();
+ });
+
+ QUnit.test('no_create option on a many2one', function (assert) {
+ assert.expect(1);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="product_id" options="{\'no_create\': True}"/>' +
+ '</sheet>' +
+ '</form>',
+ });
+
+ form.$('.o_field_many2one input').focus();
+ form.$('.o_field_many2one input').val('new partner').trigger('keyup').trigger('focusout');
+
+ assert.strictEqual($('.modal').length, 0, "should not display the create modal");
+ form.destroy();
+ });
+
+ QUnit.test('can_create and can_write option on a many2one', function (assert) {
+ assert.expect(5);
+
+ this.data.product.options = {
+ can_create: "false",
+ can_write: "false",
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="product_id" can_create="false" can_write="false"/>' +
+ '</sheet>' +
+ '</form>',
+ archs: {
+ 'product,false,form': '<form string="Products"><field name="display_name"/></form>',
+ },
+ mockRPC: function (route) {
+ if (route === '/web/dataset/call_kw/product/get_formview_id') {
+ return $.when(false);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ form.$('.o_field_many2one input').click();
+ assert.strictEqual($('.ui-autocomplete .o_m2o_dropdown_option:contains(Create)').length, 0,
+ "there shouldn't be any option to search and create");
+
+ $('.ui-autocomplete li:contains(xpad)').mouseenter().click();
+ assert.strictEqual(form.$('.o_field_many2one input').val(), "xpad",
+ "the correct record should be selected");
+ assert.strictEqual(form.$('.o_field_many2one .o_external_button').length, 1,
+ "there should be an external button displayed");
+
+ form.$('.o_field_many2one .o_external_button').click();
+ assert.strictEqual($('.modal .o_form_view.o_form_readonly').length, 1,
+ "there should be a readonly form view opened");
+
+ $('.modal .o_form_button_cancel').click();
+
+ form.$('.o_field_many2one input').val('new product').trigger('keyup').trigger('focusout');
+
+ assert.strictEqual($('.modal').length, 0, "should not display the create modal");
+ form.destroy();
+ });
+
+ QUnit.test('pressing enter in a m2o in an editable list', function (assert) {
+ assert.expect(9);
+ var done = assert.async();
+ var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
+
+ var list = createView({
+ View: ListView,
+ model: 'partner',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="product_id"/></tree>',
+ });
+
+ list.$('td.o_data_cell:first').click();
+ assert.strictEqual(list.$('.o_selected_row').length, 1,
+ "should have a row in edit mode");
+
+ // we now write 'a' and press enter to check that the selection is
+ // working, and prevent the navigation
+ list.$('td.o_data_cell input:first').val('a').trigger('input');
+ concurrency.delay(0).then(function () {
+ var $input = list.$('td.o_data_cell input:first');
+ var $dropdown = $input.autocomplete('widget');
+ assert.ok($dropdown.is(':visible'), "autocomplete dropdown should be visible");
+
+ // we now trigger ENTER to select first choice
+ $input.trigger($.Event('keydown', {
+ which: $.ui.keyCode.ENTER,
+ keyCode: $.ui.keyCode.ENTER,
+ }));
+ assert.strictEqual($input[0], document.activeElement,
+ "input should still be focused");
+
+ // we now trigger again ENTER to make sure we can move to next line
+ $input.trigger($.Event('keydown', {
+ which: $.ui.keyCode.ENTER,
+ keyCode: $.ui.keyCode.ENTER,
+ }));
+
+ assert.notOk(document.contains($input[0]),
+ "input should no longer be in dom");
+ assert.ok(list.$('tr.o_data_row:eq(1)').hasClass('o_selected_row'),
+ "second row should now be selected");
+
+ // we now write again 'a' in the cell to select xpad. We will now
+ // test with the tab key
+ list.$('td.o_data_cell input:first').val('a').trigger('input');
+ return concurrency.delay(0);
+ }).then(function () {
+ var $input = list.$('td.o_data_cell input:first');
+ var $dropdown = $input.autocomplete('widget');
+ assert.ok($dropdown.is(':visible'), "autocomplete dropdown should be visible");
+ $input.trigger($.Event('keydown', {
+ which: $.ui.keyCode.TAB,
+ keyCode: $.ui.keyCode.TAB,
+ }));
+ assert.strictEqual($input[0], document.activeElement,
+ "input should still be focused");
+
+ // we now trigger again ENTER to make sure we can move to next line
+ $input.trigger($.Event('keydown', {
+ which: $.ui.keyCode.TAB,
+ keyCode: $.ui.keyCode.TAB,
+ }));
+
+ assert.notOk(document.contains($input[0]),
+ "input should no longer be in dom");
+ assert.ok(list.$('tr.o_data_row:eq(2)').hasClass('o_selected_row'),
+ "third row should now be selected");
+ list.destroy();
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
+ done();
+ });
+ });
+
+ QUnit.test('pressing ENTER on a \'no_quick_create\' many2one should open a M2ODialog', function (assert) {
+ var done = assert.async();
+ assert.expect(2);
+
+ var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="trululu" options="{\'no_quick_create\': True}"/>' +
+ '<field name="foo"/>' +
+ '</form>',
+ archs: {
+ 'partner,false,form': '<form string="Partners"><field name="display_name"/></form>',
+ },
+ });
+
+ var $input = form.$('.o_field_many2one input');
+ $input.val("Something that does not exist").trigger('input');
+ $('.ui-autocomplete .ui-menu-item a:contains(Create and)').trigger('mouseenter');
+ concurrency.delay(0).then(function () {
+ $input.trigger($.Event('keydown', {
+ which: $.ui.keyCode.ENTER,
+ keyCode: $.ui.keyCode.ENTER,
+ }));
+ $input.trigger($.Event('keypress', {
+ which: $.ui.keyCode.ENTER,
+ keyCode: $.ui.keyCode.ENTER,
+ }));
+ $input.trigger($.Event('keyup', {
+ which: $.ui.keyCode.ENTER,
+ keyCode: $.ui.keyCode.ENTER,
+ }));
+ concurrency.delay(0).then(function () {
+ $input.blur();
+ assert.strictEqual($('.modal').length, 1,
+ "should have one modal in body");
+ // Check that discarding clears $input
+ $('.modal .o_form_button_cancel').click();
+ assert.strictEqual($input.val(), '',
+ "the field should be empty");
+ form.destroy();
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
+ done();
+ });
+ });
+ });
+
+ QUnit.test('select a value by pressing TAB on a many2one with onchange', function (assert) {
+ var done = assert.async();
+ assert.expect(3);
+
+ this.data.partner.onchanges.trululu = function () { };
+
+ var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
+ var def = $.Deferred();
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="trululu"/>' +
+ '<field name="display_name"/>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'onchange') {
+ return $.when(def).then(_.constant(result));
+ }
+ return result;
+ },
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ var $input = form.$('.o_field_many2one input');
+ $input.val("first").trigger('input');
+ concurrency.delay(0).then(function () {
+ var evOptions = {
+ which: $.ui.keyCode.TAB,
+ keyCode: $.ui.keyCode.TAB,
+ };
+ $input.trigger($.Event('keydown', evOptions));
+ $input.trigger($.Event('keypress', evOptions));
+ $input.trigger($.Event('keyup', evOptions));
+
+ // simulate a focusout (e.g. because the user clicks outside)
+ // before the onchange returns
+ form.$('.o_field_char').focus();
+
+ assert.strictEqual($('.modal').length, 0,
+ "there shouldn't be any modal in body");
+
+ // unlock the onchange
+ def.resolve();
+
+ assert.strictEqual($input.val(), 'first record',
+ "first record should have been selected");
+ assert.strictEqual($('.modal').length, 0,
+ "there shouldn't be any modal in body");
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
+ form.destroy();
+ done();
+ });
+ });
+
+ QUnit.test('many2one in editable list + onchange, with enter [REQUIRE FOCUS]', function (assert) {
+ assert.expect(6);
+ var done = assert.async();
+ var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
+
+ this.data.partner.onchanges.product_id = function (obj) {
+ obj.int_field = obj.product_id || 0;
+ };
+
+ var def = $.Deferred();
+
+ var list = createView({
+ View: ListView,
+ model: 'partner',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="product_id"/><field name="int_field"/></tree>',
+ mockRPC: function (route, args) {
+ if (args.method) {
+ assert.step(args.method);
+ }
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'onchange') {
+ return def.then(_.constant(result));
+ }
+ return result;
+ },
+ });
+
+ list.$('td.o_data_cell:first').click();
+ list.$('td.o_data_cell input:first').val('a').trigger('input');
+ concurrency.delay(0).then(function () {
+ var $input = list.$('td.o_data_cell input:first');
+ $input.trigger($.Event('keydown', {
+ which: $.ui.keyCode.ENTER,
+ keyCode: $.ui.keyCode.ENTER,
+ }));
+ $input.trigger($.Event('keyup', {
+ which: $.ui.keyCode.ENTER,
+ keyCode: $.ui.keyCode.ENTER,
+ }));
+ def.resolve();
+ $input.trigger($.Event('keydown', {
+ which: $.ui.keyCode.ENTER,
+ keyCode: $.ui.keyCode.ENTER,
+ }));
+ assert.strictEqual($('.modal').length, 0, "should not have any modal in DOM");
+ assert.verifySteps(['name_search', 'onchange', 'write', 'read']);
+ list.destroy();
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
+ done();
+ });
+ });
+
+ QUnit.test('many2one in editable list + onchange, with enter, part 2 [REQUIRE FOCUS]', function (assert) {
+ // this is the same test as the previous one, but the onchange is just
+ // resolved slightly later
+ assert.expect(6);
+ var done = assert.async();
+ var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
+
+ this.data.partner.onchanges.product_id = function (obj) {
+ obj.int_field = obj.product_id || 0;
+ };
+
+ var def = $.Deferred();
+
+ var list = createView({
+ View: ListView,
+ model: 'partner',
+ data: this.data,
+ arch: '<tree editable="bottom"><field name="product_id"/><field name="int_field"/></tree>',
+ mockRPC: function (route, args) {
+ if (args.method) {
+ assert.step(args.method);
+ }
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'onchange') {
+ return def.then(_.constant(result));
+ }
+ return result;
+ },
+ });
+
+ list.$('td.o_data_cell:first').click();
+ list.$('td.o_data_cell input:first').val('a').trigger('input');
+ concurrency.delay(0).then(function () {
+ var $input = list.$('td.o_data_cell input:first');
+ $input.trigger($.Event('keydown', {
+ which: $.ui.keyCode.ENTER,
+ keyCode: $.ui.keyCode.ENTER,
+ }));
+ $input.trigger($.Event('keyup', {
+ which: $.ui.keyCode.ENTER,
+ keyCode: $.ui.keyCode.ENTER,
+ }));
+ $input.trigger($.Event('keydown', {
+ which: $.ui.keyCode.ENTER,
+ keyCode: $.ui.keyCode.ENTER,
+ }));
+ def.resolve();
+ assert.strictEqual($('.modal').length, 0, "should not have any modal in DOM");
+ assert.verifySteps(['name_search', 'onchange', 'write', 'read']);
+ list.destroy();
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
+ done();
+ });
+ });
+
+ QUnit.test('many2one: domain updated by an onchange', function (assert) {
+ assert.expect(2);
+
+ this.data.partner.onchanges = {
+ int_field: function () { },
+ };
+
+ var domain = [];
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="int_field"/>' +
+ '<field name="trululu"/>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ domain = [['id', 'in', [10]]];
+ return $.when({
+ domain: {
+ trululu: domain,
+ unexisting_field: domain,
+ }
+ });
+ }
+ if (args.method === 'name_search') {
+ assert.deepEqual(args.kwargs.args, domain,
+ "sent domain should be correct");
+ }
+ return this._super(route, args);
+ },
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ // trigger a name_search (domain should be [])
+ form.$('.o_field_widget[name=trululu] input').click();
+ // close the dropdown
+ form.$('.o_field_widget[name=trululu] input').click();
+ // trigger an onchange that will update the domain
+ form.$('.o_field_widget[name=int_field]').val(2).trigger('input');
+ // trigger a name_search (domain should be [['id', 'in', [10]]])
+ form.$('.o_field_widget[name=trululu] input').click();
+
+ form.destroy();
+ });
+
+ QUnit.test('many2one in one2many: domain updated by an onchange', function (assert) {
+ assert.expect(3);
+
+ this.data.partner.onchanges = {
+ trululu: function () { },
+ };
+
+ var domain = [];
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="p">' +
+ '<tree editable="bottom">' +
+ '<field name="trululu"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ return $.when({
+ domain: {
+ trululu: domain,
+ },
+ });
+ }
+ if (args.method === 'name_search') {
+ assert.deepEqual(args.kwargs.args, domain,
+ "sent domain should be correct");
+ }
+ return this._super(route, args);
+ },
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ // add a first row with a specific domain for the m2o
+ domain = [['id', 'in', [10]]]; // domain for subrecord 1
+ form.$('.o_field_x2many_list_row_add a').click(); // triggers the onchange
+ form.$('.o_field_widget[name=trululu] input').click(); // triggers the name_search
+
+ // add a second row with another domain for the m2o
+ domain = [['id', 'in', [5]]]; // domain for subrecord 2
+ form.$('.o_field_x2many_list_row_add a').click(); // triggers the onchange
+ form.$('.o_field_widget[name=trululu] input').click(); // triggers the name_search
+
+ // check again the first row to ensure that the domain hasn't change
+ domain = [['id', 'in', [10]]]; // domain for subrecord 1 should have been kept
+ form.$('.o_data_row:first .o_data_cell').click();
+ form.$('.o_field_widget[name=trululu] input').click(); // triggers the name_search
+
+ form.destroy();
+ });
+
+ QUnit.test('updating a many2one from a many2many', function (assert) {
+ assert.expect(4);
+
+ this.data.turtle.records[1].turtle_trululu = 1;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group>' +
+ '<field name="turtles">' +
+ '<tree editable="bottom">' +
+ '<field name="display_name"/>' +
+ '<field name="turtle_trululu"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</form>',
+ res_id: 1,
+ archs: {
+ 'partner,false,form': '<form string="Trululu"><field name="display_name"/></form>',
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'get_formview_id') {
+ assert.deepEqual(args.args[0], [1], "should call get_formview_id with correct id");
+ return $.when(false);
+ }
+ return this._super(route, args);
+ },
+ });
+
+ // Opening the modal
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_data_row td:contains(first record)').click();
+ form.$('.o_external_button').click();
+ assert.strictEqual($('.modal').length, 1,
+ "should have one modal in body");
+
+ // Changing the 'trululu' value
+ $('.modal input[name="display_name"]').val('test').trigger('input');
+ $('.modal button.btn-primary').click();
+
+ // Test whether the value has changed
+ assert.strictEqual($('.modal').length, 0,
+ "the modal should be closed");
+ assert.equal(form.$('.o_data_cell:contains(test)').text(), 'test',
+ "the partner name should have been updated to 'test'");
+
+ form.destroy();
+ });
+
+ QUnit.test('x2many list sorted by many2one', function (assert) {
+ assert.expect(3);
+
+ this.data.partner.records[0].p = [1, 2, 4];
+ this.data.partner.fields.trululu.sortable = true;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="id"/>' +
+ '<field name="trululu"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ assert.strictEqual(form.$('.o_data_row .o_list_number').text(), '124',
+ "should have correct order initially");
+
+ form.$('.o_list_view thead th:nth(1)').click();
+
+ assert.strictEqual(form.$('.o_data_row .o_list_number').text(), '412',
+ "should have correct order (ASC)");
+
+ form.$('.o_list_view thead th:nth(1)').click();
+
+ assert.strictEqual(form.$('.o_data_row .o_list_number').text(), '214',
+ "should have correct order (DESC)");
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many with extra field from server not in form', function (assert) {
+ assert.expect(6);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p" >' +
+ '<tree>' +
+ '<field name="datetime"/>' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ archs: {
+ 'partner,false,form': '<form>' +
+ '<field name="display_name"/>' +
+ '</form>'},
+ mockRPC: function(route, args) {
+ if (route === '/web/dataset/call_kw/partner/write') {
+ args.args[1].p[0][2].datetime = '2018-04-05 12:00:00';
+ }
+ return this._super.apply(this, arguments);
+ }
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+
+ var x2mList = form.$('.o_field_x2many_list[name=p]');
+
+ // Add a record in the list
+ x2mList.find('.o_field_x2many_list_row_add a').click();
+
+ var modal = $('.modal-lg');
+
+ var nameInput = modal.find('input.o_input[name=display_name]');
+ nameInput.val('michelangelo').trigger('input');
+
+ // Save the record in the modal (though it is still virtual)
+ modal.find('.btn-primary').first().click();
+
+ assert.equal(x2mList.find('.o_data_row').length, 1,
+ 'There should be 1 records in the x2m list');
+
+ var newlyAdded = x2mList.find('.o_data_row').eq(0);
+
+ assert.equal(newlyAdded.find('.o_data_cell').first().text(), '',
+ 'The create_date field should be empty');
+ assert.equal(newlyAdded.find('.o_data_cell').eq(1).text(), 'michelangelo',
+ 'The display name field should have the right value');
+
+ // Save the whole thing
+ form.$buttons.find('.o_form_button_save').click();
+
+ x2mList = form.$('.o_field_x2many_list[name=p]');
+
+ // Redo asserts in RO mode after saving
+ assert.equal(x2mList.find('.o_data_row').length, 1,
+ 'There should be 1 records in the x2m list');
+
+ newlyAdded = x2mList.find('.o_data_row').eq(0);
+
+ assert.equal(newlyAdded.find('.o_data_cell').first().text(), '04/05/2018 12:00:00',
+ 'The create_date field should have the right value');
+ assert.equal(newlyAdded.find('.o_data_cell').eq(1).text(), 'michelangelo',
+ 'The display name field should have the right value');
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many with extra field from server not in (inline) form', function (assert) {
+ assert.expect(1);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p" >' +
+ '<tree>' +
+ '<field name="datetime"/>' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '<form>' +
+ '<field name="display_name"/>' +
+ '</form>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ var x2mList = form.$('.o_field_x2many_list[name=p]');
+
+ // Add a record in the list
+ x2mList.find('.o_field_x2many_list_row_add a').click();
+
+ var modal = $('.modal-lg');
+
+ var nameInput = modal.find('input.o_input[name=display_name]');
+ nameInput.val('michelangelo').trigger('input');
+
+ // Save the record in the modal (though it is still virtual)
+ modal.find('.btn-primary').first().click();
+
+ assert.equal(x2mList.find('.o_data_row').length, 1,
+ 'There should be 1 records in the x2m list');
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many with extra X2many field from server not in inline form', function (assert) {
+ assert.expect(1);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p" >' +
+ '<tree>' +
+ '<field name="turtles"/>' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '<form>' +
+ '<field name="display_name"/>' +
+ '</form>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ var x2mList = form.$('.o_field_x2many_list[name=p]');
+
+ // Add a first record in the list
+ x2mList.find('.o_field_x2many_list_row_add a').click();
+
+ // Save & New
+ $('.modal-lg').find('.btn-primary').eq(1).click();
+
+ // Save & Close
+ $('.modal-lg').find('.btn-primary').eq(0).click();
+
+ assert.equal(x2mList.find('.o_data_row').length, 2,
+ 'There should be 2 records in the x2m list');
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many invisible depends on parent field', function (assert) {
+ assert.expect(4);
+
+ this.data.partner.records[0].p = [2];
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ 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" attrs="{\'column_invisible\': [(\'parent.product_id\', \'!=\', False)]}"/>' +
+ '<field name="bar" attrs="{\'column_invisible\': [(\'parent.bar\', \'=\', False)]}"/>' +
+ '</tree>' +
+ '</field>' +
+ '</page>' +
+ '</notebook>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+ 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");
+ form.destroy();
+ });
+
+ QUnit.test('one2many column visiblity depends on onchange of parent field', function (assert) {
+ assert.expect(3);
+
+ this.data.partner.records[0].p = [2];
+ this.data.partner.records[0].bar = false;
+
+ this.data.partner.onchanges.p = function (obj) {
+ // set bar to true when line is added
+ if (obj.p.length > 1 && obj.p[1][2].foo === 'New line') {
+ obj.bar = true;
+ }
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'<form>' +
+ '<field name="bar"/>' +
+ '<field name="p">' +
+ '<tree editable="bottom">' +
+ '<field name="foo"/>' +
+ '<field name="int_field" attrs="{\'column_invisible\': [(\'parent.bar\', \'=\', False)]}"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ // bar is false so there should be 1 column
+ assert.strictEqual(form.$('th').length, 1,
+ "should be only 1 column ('foo') in the one2many");
+ assert.strictEqual(form.$('.o_list_view .o_data_row').length, 1, "should contain one row");
+
+ form.$buttons.find('.o_form_button_edit').click();
+
+ // add a new o2m record
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('.o_field_one2many input:first').focus();
+ form.$('.o_field_one2many input:first').val('New line').trigger('input');
+ form.$el.click(); // click outside to save a line
+
+ assert.strictEqual(form.$('th').length, 2, "should be 2 columns('foo' + 'int_field')");
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many column_invisible on view not inline', function (assert) {
+ assert.expect(4);
+
+ this.data.partner.records[0].p = [2];
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ 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"/>' +
+ '</page>' +
+ '</notebook>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ archs: {
+ 'partner,false,list': '<tree>' +
+ '<field name="foo" attrs="{\'column_invisible\': [(\'parent.product_id\', \'!=\', False)]}"/>' +
+ '<field name="bar" attrs="{\'column_invisible\': [(\'parent.bar\', \'=\', False)]}"/>' +
+ '</tree>',
+ },
+ });
+ 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");
+ form.destroy();
+ });
+ });
+});
+});
diff --git a/addons/web/static/tests/fields/relational_fields/field_one_to_many_tests.js b/addons/web/static/tests/fields/relational_fields/field_one_to_many_tests.js
new file mode 100644
index 00000000000..7181b5267b1
--- /dev/null
+++ b/addons/web/static/tests/fields/relational_fields/field_one_to_many_tests.js
@@ -0,0 +1,8176 @@
+odoo.define('web.field_one_to_many_tests', function (require) {
+"use strict";
+
+var AbstractField = require('web.AbstractField');
+var concurrency = require('web.concurrency');
+var FormView = require('web.FormView');
+var ListRenderer = require('web.ListRenderer');
+var relationalFields = require('web.relational_fields');
+var testUtils = require('web.test_utils');
+var fieldUtils = require('web.field_utils');
+
+var createView = testUtils.createView;
+
+QUnit.module('fields', {}, function () {
+
+ QUnit.module('relational_fields', {
+ beforeEach: function () {
+ this.data = {
+ partner: {
+ fields: {
+ display_name: { string: "Displayed name", type: "char" },
+ foo: { string: "Foo", type: "char", default: "My little Foo Value" },
+ bar: { string: "Bar", type: "boolean", default: true },
+ int_field: { string: "int_field", type: "integer", sortable: true },
+ qux: { string: "Qux", type: "float", digits: [16, 1] },
+ p: { string: "one2many field", type: "one2many", relation: 'partner', relation_field: 'trululu' },
+ turtles: { string: "one2many turtle field", type: "one2many", relation: 'turtle', relation_field: 'turtle_trululu' },
+ trululu: { string: "Trululu", type: "many2one", relation: 'partner' },
+ timmy: { string: "pokemon", type: "many2many", relation: 'partner_type' },
+ product_id: { string: "Product", type: "many2one", relation: 'product' },
+ color: {
+ type: "selection",
+ selection: [['red', "Red"], ['black', "Black"]],
+ default: 'red',
+ string: "Color",
+ },
+ date: { string: "Some Date", type: "date" },
+ datetime: { string: "Datetime Field", type: 'datetime' },
+ user_id: { string: "User", type: 'many2one', relation: 'user' },
+ reference: {
+ string: "Reference Field", type: 'reference', selection: [
+ ["product", "Product"], ["partner_type", "Partner Type"], ["partner", "Partner"]]
+ },
+ },
+ records: [{
+ id: 1,
+ display_name: "first record",
+ bar: true,
+ foo: "yop",
+ int_field: 10,
+ qux: 0.44,
+ p: [],
+ turtles: [2],
+ timmy: [],
+ trululu: 4,
+ user_id: 17,
+ reference: 'product,37',
+ }, {
+ id: 2,
+ display_name: "second record",
+ bar: true,
+ foo: "blip",
+ int_field: 9,
+ qux: 13,
+ p: [],
+ timmy: [],
+ trululu: 1,
+ product_id: 37,
+ date: "2017-01-25",
+ datetime: "2016-12-12 10:55:05",
+ user_id: 17,
+ }, {
+ id: 4,
+ display_name: "aaa",
+ bar: false,
+ }],
+ onchanges: {},
+ },
+ product: {
+ fields: {
+ name: { string: "Product Name", type: "char" }
+ },
+ records: [{
+ id: 37,
+ display_name: "xphone",
+ }, {
+ id: 41,
+ display_name: "xpad",
+ }]
+ },
+ partner_type: {
+ fields: {
+ name: { string: "Partner Type", type: "char" },
+ color: { string: "Color index", type: "integer" },
+ },
+ records: [
+ { id: 12, display_name: "gold", color: 2 },
+ { id: 14, display_name: "silver", color: 5 },
+ ]
+ },
+ turtle: {
+ fields: {
+ display_name: { string: "Displayed name", type: "char" },
+ turtle_foo: { string: "Foo", type: "char" },
+ turtle_bar: { string: "Bar", type: "boolean", default: true },
+ turtle_int: { string: "int", type: "integer", sortable: true },
+ turtle_qux: { string: "Qux", type: "float", digits: [16, 1], required: true, default: 1.5 },
+ turtle_description: { string: "Description", type: "text" },
+ turtle_trululu: { string: "Trululu", type: "many2one", relation: 'partner' },
+ turtle_ref: {
+ string: "Reference", type: 'reference', selection: [
+ ["product", "Product"], ["partner", "Partner"]]
+ },
+ product_id: { string: "Product", type: "many2one", relation: 'product', required: true },
+ partner_ids: { string: "Partner", type: "many2many", relation: 'partner' },
+ },
+ records: [{
+ id: 1,
+ display_name: "leonardo",
+ turtle_bar: true,
+ turtle_foo: "yop",
+ partner_ids: [],
+ }, {
+ id: 2,
+ display_name: "donatello",
+ turtle_bar: true,
+ turtle_foo: "blip",
+ turtle_int: 9,
+ partner_ids: [2, 4],
+ }, {
+ id: 3,
+ display_name: "raphael",
+ product_id: 37,
+ turtle_bar: false,
+ turtle_foo: "kawa",
+ turtle_int: 21,
+ turtle_qux: 9.8,
+ partner_ids: [],
+ turtle_ref: 'product,37',
+ }],
+ onchanges: {},
+ },
+ user: {
+ fields: {
+ name: { string: "Name", type: "char" },
+ partner_ids: { string: "one2many partners field", type: "one2many", relation: 'partner', relation_field: 'user_id' },
+ },
+ records: [{
+ id: 17,
+ name: "Aline",
+ partner_ids: [1, 2],
+ }, {
+ id: 19,
+ name: "Christine",
+ }]
+ },
+ };
+ }
+ }, function () {
+ QUnit.module('FieldOne2Many');
+
+ QUnit.test('New record with a o2m also with 2 new records, ordered, and resequenced', function (assert) {
+ assert.expect(3);
+
+ // Needed to have two new records in a single stroke
+ this.data.partner.onchanges = {
+ foo: function (obj) {
+ obj.p = [
+ [5],
+ [0, 0, { trululu: false }],
+ [0, 0, { trululu: false }],
+ ]
+ }
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="foo" />' +
+ '<field name="p">' +
+ '<tree editable="bottom" default_order="int_field">' +
+ '<field name="int_field" widget="handle"/>' +
+ '<field name="trululu"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ viewOptions: {
+ mode: 'create',
+ },
+ mockRPC: function (route, args) {
+ assert.step(args.method + ' ' + args.model)
+ return this._super(route, args);
+ },
+ });
+
+ // change the int_field through drag and drop
+ // that way, we'll trigger the sorting and the name_get
+ // of the lines of "p"
+ testUtils.dragAndDrop(
+ form.$('.ui-sortable-handle').eq(1),
+ form.$('tbody tr').first(),
+ { position: 'top' }
+ );
+
+ // Only those two should have been called
+ // name_get on trululu would trigger an traceback
+ assert.verifySteps(['default_get partner', 'onchange partner']);
+
+ form.destroy();
+ });
+
+ QUnit.test('O2M List with pager, decoration and default_order: add and cancel adding', function (assert) {
+ assert.expect(3);
+
+ // The decoration on the list implies that its condition will be evaluated
+ // against the data of the field (actual records *displayed*)
+ // If one data is wrongly formed, it will crash
+ // This test adds then cancels a record in a paged, ordered, and decorated list
+ // That implies prefetching of records for sorting
+ // and evaluation of the decoration against *visible records*
+
+ this.data.partner.records[0].p = [2, 4];
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<tree editable="bottom" limit="1" decoration-muted="foo != False" default_order="display_name">' +
+ '<field name="foo" invisible="1"/>' +
+ '<field name="display_name" />' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ form.$('.o_field_x2many_list .o_field_x2many_list_row_add a').click();
+
+ var $dataRows = form.$('.o_field_x2many_list .o_data_row');
+ assert.equal($dataRows.length, 2,
+ 'There should be 2 rows');
+
+ var $expectedSelectedRow = $dataRows.eq(1);
+ var $actualSelectedRow = form.$('.o_selected_row');
+ assert.equal($actualSelectedRow[0], $expectedSelectedRow[0],
+ 'The selected row should be the new one');
+
+ // Cancel Creation
+ var escapeKey = $.ui.keyCode.ESCAPE;
+ $actualSelectedRow.find('input').trigger(
+ $.Event('keydown', { which: escapeKey, keyCode: escapeKey }));
+
+ $dataRows = form.$('.o_field_x2many_list .o_data_row');
+ assert.equal($dataRows.length, 1,
+ 'There should be 1 row');
+
+ form.destroy();
+ });
+
+ QUnit.test('O2M with parented m2o and domain on parent.m2o', function (assert) {
+ assert.expect(3);
+
+ /* records in an o2m can have a m2o pointing to themselves
+ * in that case, a domain evaluation on that field followed by name_search
+ * shouldn't send virtual_ids to the server
+ */
+
+ this.data.turtle.fields.parent_id = { string: "Parent", type: "many2one", relation: 'turtle' };
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree>' +
+ '<field name="parent_id" />' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ archs: {
+ 'turtle,false,form': '<form><field name="parent_id" domain="[(\'id\', \'in\', parent.turtles)]"/></form>',
+ },
+
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/call_kw/turtle/name_search') {
+ // We are going to pass twice here
+ // First time, we really have nothing
+ // Second time, a virtual_id has been created
+ assert.deepEqual(args.kwargs.args, [['id', 'in', []]]);
+ }
+ return this._super(route, args);
+ }
+ });
+
+ form.$('.o_field_x2many_list[name=turtles] .o_field_x2many_list_row_add a').click();
+ var $modal = $('.modal-content');
+
+ var $turtleParent = $modal.find('.o_field_many2one input');
+ var $dropdown = $turtleParent.autocomplete('widget');
+
+ $turtleParent.click();
+
+ $dropdown.find('li.o_m2o_dropdown_option:contains(Create)').first().mouseenter().click();
+
+ $modal = $('.modal-content');
+
+ $modal.eq(1).find('.modal-footer .btn-primary').eq(0).click(); // Confirm new Record
+ $modal.eq(0).find('.modal-footer .btn-primary').eq(1).click(); // Save & New
+
+ assert.equal(form.$('.o_data_row').length, 1,
+ 'The main record should have the new record in its o2m');
+
+ $modal = $('.modal-content');
+ $modal.find('.o_field_many2one input').click();
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many list editable with cell readonly modifier', function (assert) {
+ assert.expect(4);
+
+ this.data.partner.records[0].p = [2];
+ this.data.partner.records[1].turtles = [1, 2];
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<tree editable="bottom">' +
+ '<field name="turtles" invisible="1"/>' +
+ '<field name="foo" attrs="{&quot;readonly&quot; : [(&quot;turtles&quot;, &quot;!=&quot;, [])] }"/>' +
+ '<field name="qux" attrs="{&quot;readonly&quot; : [(&quot;turtles&quot;, &quot;!=&quot;, [])] }"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/call_kw/partner/write') {
+ assert.deepEqual(args.args[1].p[1][2], { foo: 'ff', qux: 99 },
+ 'The right values should be written');
+ }
+ return this._super(route, args);
+ }
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ var $targetInput = $('.o_selected_row .o_input[name=foo]');
+ assert.equal($targetInput[0], document.activeElement,
+ 'The first input of the line should have the focus');
+
+ // Simulating hitting the 'f' key twice
+ $targetInput.val('f').trigger('input');
+ $targetInput.val($targetInput.val() + 'f').trigger('input');
+
+ assert.equal($targetInput[0], document.activeElement,
+ 'The first input of the line should still have the focus');
+
+ // Simulating a TAB key
+ $targetInput.trigger($.Event('keydown', { which: 9, keyCode: 9 }));
+
+ var $secondTarget = $('.o_selected_row .o_input[name=qux]');
+
+ assert.equal($secondTarget[0], document.activeElement,
+ 'The second input of the line should have the focus after the TAB press');
+
+
+ $secondTarget.val(9).trigger('input');
+ $secondTarget.val($secondTarget.val() + 9).trigger('input');
+
+ form.$buttons.find('.o_form_button_save').click();
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many basic properties', function (assert) {
+ assert.expect(6);
+
+ this.data.partner.records[0].p = [2];
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<notebook>' +
+ '<page string="Partner page">' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</page>' +
+ '</notebook>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+
+ assert.strictEqual(form.$('td.o_list_record_selector').length, 0,
+ "embedded one2many should not have a selector");
+ assert.ok(!form.$('.o_field_x2many_list_row_add').length,
+ "embedded one2many should not be editable");
+ assert.ok(!form.$('td.o_list_record_remove').length,
+ "embedded one2many records should not have a remove icon");
+
+ form.$buttons.find('.o_form_button_edit').click();
+
+ assert.ok(form.$('.o_field_x2many_list_row_add').length,
+ "embedded one2many should now be editable");
+
+ assert.strictEqual(form.$('.o_field_x2many_list_row_add').attr('colspan'), "2",
+ "should have colspan 2 (one for field foo, one for being below remove icon)");
+
+ assert.ok(form.$('td.o_list_record_remove').length,
+ "embedded one2many records should have a remove icon");
+ form.destroy();
+ });
+
+ QUnit.test('transferring class attributes in one2many sub fields', function (assert) {
+ assert.expect(3);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree editable="bottom">' +
+ '<field name="turtle_foo" class="hey"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ assert.strictEqual(form.$('td.hey').length, 1,
+ 'should have a td with the desired class');
+
+ form.$buttons.find('.o_form_button_edit').click();
+
+ assert.strictEqual(form.$('td.hey').length, 1,
+ 'should have a td with the desired class');
+
+ form.$('td.o_data_cell').click();
+
+ assert.strictEqual(form.$('input[name="turtle_foo"].hey').length, 1,
+ 'should have an input with the desired class');
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many with date and datetime', function (assert) {
+ assert.expect(2);
+
+ this.data.partner.records[0].p = [2];
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<notebook>' +
+ '<page string="Partner page">' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="date"/>' +
+ '<field name="datetime"/>' +
+ '</tree>' +
+ '</field>' +
+ '</page>' +
+ '</notebook>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ session: {
+ getTZOffset: function () {
+ return 120;
+ },
+ },
+ });
+ assert.strictEqual(form.$('td:eq(0)').text(), "01/25/2017",
+ "should have formatted the date");
+ assert.strictEqual(form.$('td:eq(1)').text(), "12/12/2016 12:55:05",
+ "should have formatted the datetime");
+ form.destroy();
+ });
+
+ QUnit.test('rendering with embedded one2many', function (assert) {
+ assert.expect(2);
+
+ this.data.partner.records[0].p = [2];
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<notebook>' +
+ '<page string="P page">' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="foo"/>' +
+ '<field name="bar"/>' +
+ '</tree>' +
+ '</field>' +
+ '</page>' +
+ '</notebook>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ assert.strictEqual(form.$('th:contains(Foo)').length, 1,
+ "embedded one2many should have a column titled according to foo");
+ assert.strictEqual(form.$('td:contains(blip)').length, 1,
+ "embedded one2many should have a cell with relational value");
+ form.destroy();
+ });
+
+ QUnit.test('use the limit attribute in arch (in field o2m inline tree view)', function (assert) {
+ assert.expect(2);
+
+ this.data.partner.records[0].turtles = [1, 2, 3];
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree limit="2">' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.model === 'turtle') {
+ assert.deepEqual(args.args[0], [1, 2],
+ 'should only load first 2 records');
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual(form.$('.o_data_row').length, 2,
+ 'should display 2 data rows');
+ form.destroy();
+ });
+
+ QUnit.test('use the limit attribute in arch (in field o2m non inline tree view)', function (assert) {
+ assert.expect(2);
+
+ this.data.partner.records[0].turtles = [1, 2, 3];
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles"/>' +
+ '</form>',
+ archs: {
+ 'turtle,false,list': '<tree limit="2"><field name="turtle_foo"/></tree>',
+ },
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.model === 'turtle' && args.method === 'read') {
+ assert.deepEqual(args.args[0], [1, 2],
+ 'should only load first 2 records');
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual(form.$('.o_data_row').length, 2,
+ 'should display 2 data rows');
+ form.destroy();
+ });
+
+ QUnit.test('one2many with default_order on view not inline', function (assert) {
+ assert.expect(1);
+
+ this.data.partner.records[0].turtles = [1, 2, 3];
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<notebook>' +
+ '<page string="Turtles">' +
+ '<field name="turtles"/>' +
+ '</page>' +
+ '</notebook>' +
+ '</sheet>' +
+ '</form>',
+ archs: {
+ 'turtle,false,list': '<tree default_order="turtle_foo">' +
+ '<field name="turtle_int"/>' +
+ '<field name="turtle_foo"/>' +
+ '</tree>',
+ },
+ res_id: 1,
+ });
+ assert.strictEqual(form.$('.o_field_one2many .o_list_view .o_data_row').text(), "9blip21kawa0yop",
+ "the default order should be correctly applied");
+ form.destroy();
+ });
+
+ QUnit.test('embedded one2many with widget', function (assert) {
+ assert.expect(1);
+
+ this.data.partner.records[0].p = [2];
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<notebook>' +
+ '<page string="P page">' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="int_field" widget="handle"/>' +
+ '<field name="foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</page>' +
+ '</notebook>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ assert.strictEqual(form.$('span.o_row_handle').length, 1, "should have 1 handles");
+ form.destroy();
+ });
+
+ QUnit.test('embedded one2many with handle widget', function (assert) {
+ assert.expect(10);
+
+ var nbConfirmChange = 0;
+ testUtils.patch(ListRenderer, {
+ confirmChange: function () {
+ nbConfirmChange++;
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ this.data.partner.records[0].turtles = [1, 2, 3];
+
+ 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,
+ });
+
+ testUtils.intercept(form, "field_changed", function (event) {
+ assert.step(event.data.changes.turtles.data.turtle_int.toString());
+ }, true);
+
+ 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();
+
+ assert.strictEqual(form.$('td.o_data_cell:not(.o_handle_cell)').text(), "yopblipkawa",
+ "should still have the 3 rows in the correct order");
+ assert.strictEqual(nbConfirmChange, 0, "should not have confirmed any change yet");
+
+ // 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(nbConfirmChange, 1, "should have confirmed changes only once");
+ assert.verifySteps(["0", "1"],
+ "sequences values should be incremental starting from the previous minimum one");
+
+ assert.strictEqual(form.$('td.o_data_cell:not(.o_handle_cell)').text(), "blipyopkawa",
+ "should have the 3 rows in the new order");
+
+ form.$buttons.find('.o_form_button_save').click();
+
+ assert.deepEqual(_.map(this.data.turtle.records, function (turtle) {
+ return _.pick(turtle, 'id', 'turtle_foo', 'turtle_int');
+ }), [
+ { id: 1, turtle_foo: "yop", turtle_int: 1 },
+ { id: 2, turtle_foo: "blip", turtle_int: 0 },
+ { id: 3, turtle_foo: "kawa", turtle_int: 21 }
+ ], "should have save the changed sequence");
+
+ assert.strictEqual(form.$('td.o_data_cell:not(.o_handle_cell)').text(), "blipyopkawa",
+ "should still have the 3 rows in the new order");
+
+ testUtils.unpatch(ListRenderer);
+
+ form.destroy();
+ });
+
+ QUnit.test('onchange for embedded one2many in a one2many with a second page', function (assert) {
+ assert.expect(1);
+
+ this.data.turtle.fields.partner_ids.type = 'one2many';
+ this.data.turtle.records[0].partner_ids = [1];
+ // we need a second page, so we set two records and only display one per page
+ this.data.partner.records[0].turtles = [1, 2];
+
+ this.data.partner.onchanges = {
+ turtles: function (obj) {
+ obj.turtles = [
+ [5],
+ [1, 1, {
+ turtle_foo: "hop",
+ partner_ids: [[5], [4, 1]],
+ }],
+ [1, 2, {
+ turtle_foo: "blip",
+ partner_ids: [[5], [4, 2], [4, 4]],
+ }],
+ ];
+ },
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree editable="bottom" limit="1">' +
+ '<field name="turtle_foo"/>' +
+ '<field name="partner_ids" widget="many2many_tags"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ var expectedResultTurtles = [
+ [1, 1, {
+ turtle_foo: "hop",
+ }],
+ [1, 2, {
+ partner_ids: [[4, 2, false], [4, 4, false]],
+ turtle_foo: "blip",
+ }],
+ ];
+ assert.deepEqual(args.args[1].turtles, expectedResultTurtles,
+ "the right values should be written");
+ }
+ return this._super.apply(this, arguments);
+ }
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_data_cell').eq(1).click();
+ var $cell = form.$('.o_selected_row .o_input[name=turtle_foo]');
+ $cell.val("hop").trigger('change');
+ form.$buttons.find('.o_form_button_save').click();
+
+ form.destroy();
+ });
+
+ QUnit.test('onchange for embedded one2many in a one2many updated by server', function (assert) {
+ // here we test that after an onchange, the embedded one2many field has
+ // been updated by a new list of ids by the server response, to this new
+ // list should be correctly sent back at save time
+ assert.expect(3);
+
+ this.data.turtle.fields.partner_ids.type = 'one2many';
+ this.data.partner.records[0].turtles = [2];
+ this.data.turtle.records[1].partner_ids = [2];
+
+ this.data.partner.onchanges = {
+ turtles: function (obj) {
+ obj.turtles = [
+ [5],
+ [1, 2, {
+ turtle_foo: "hop",
+ partner_ids: [[5], [4, 2], [4, 4]],
+ }],
+ ];
+ },
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree editable="bottom">' +
+ '<field name="turtle_foo"/>' +
+ '<field name="partner_ids" widget="many2many_tags"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/call_kw/partner/write') {
+ var expectedResultTurtles = [
+ [1, 2, {
+ partner_ids: [[4, 2, false], [4, 4, false]],
+ turtle_foo: "hop",
+ }],
+ ];
+ assert.deepEqual(args.args[1].turtles, expectedResultTurtles,
+ 'The right values should be written');
+ }
+ return this._super.apply(this, arguments);
+ }
+ });
+
+ assert.deepEqual(form.$('.o_many2many_tags_cell').text().trim(), "second record",
+ "the partner_ids should be as specified at initialization");
+
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_data_cell').eq(1).click();
+ var $cell = form.$('.o_selected_row .o_input[name=turtle_foo]');
+ $cell.val("hop").trigger("change");
+ form.$buttons.find('.o_form_button_save').click();
+
+ assert.deepEqual(form.$('.o_many2many_tags_cell').text().trim().split(/\s+/),
+ ["second", "record", "aaa"],
+ 'The partner_ids should have been updated');
+
+ form.destroy();
+ });
+
+ QUnit.test('onchange for embedded one2many with handle widget', function (assert) {
+ assert.expect(2);
+
+ this.data.partner.records[0].turtles = [1, 2, 3];
+ var partnerOnchange = 0;
+ this.data.partner.onchanges = {
+ turtles: function (obj) {
+ partnerOnchange++;
+ },
+ };
+ var turtleOnchange = 0;
+ this.data.turtle.onchanges = {
+ turtle_int: function (obj) {
+ 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,
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+
+ // 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(turtleOnchange, 2, "should trigger one onchange per line updated");
+ assert.strictEqual(partnerOnchange, 1, "should trigger only one onchange on the parent");
+
+ 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, [[4, 2, false], [1, 1, { "turtle_int": 2 }], [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(3);
+
+ var ids = [];
+ for (var i = 10; i < 50; i++) {
+ var id = 10 + i;
+ ids.push(id);
+ this.data.turtle.records.push({
+ id: id,
+ turtle_int: 0,
+ turtle_foo: "#" + id,
+ });
+ }
+ ids.push(1, 2, 3);
+ this.data.partner.records[0].turtles = ids;
+ 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,
+ });
+
+ form.$('.o_pager_next').click();
+ 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' }
+ );
+
+ assert.strictEqual(form.$('.o_data_cell').text(), "blurpkawablip", "should display to record in 'turtle_int' order");
+
+ form.$buttons.find('.o_form_button_save').click();
+ form.$('.o_pager_next').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('onchange with modifiers for embedded one2many on the second page', function (assert) {
+ assert.expect(7);
+
+ var data = this.data;
+ var ids = [];
+ for (var i = 10; i < 60; i++) {
+ var id = 10 + i;
+ ids.push(id);
+ data.turtle.records.push({
+ id: id,
+ turtle_int: 0,
+ turtle_foo: "#" + id,
+ });
+ }
+ ids.push(1, 2, 3);
+ data.partner.records[0].turtles = ids;
+ data.partner.onchanges = {
+ turtles: function (obj) {
+ // TODO: make this test more 'difficult'
+ // For now, the server only returns UPDATE commands (no LINK TO)
+ // even though it should do it (for performance reasons)
+ // var turtles = obj.turtles.splice(0, 20);
+
+ var turtles = [];
+ turtles.unshift([5]);
+ // create UPDATE commands for each records (this is the server
+ // usual answer for onchange)
+ for (var k in obj.turtles) {
+ var change = obj.turtles[k];
+ var record = _.findWhere(data.turtle.records, { id: change[1] });
+ if (change[0] === 1) {
+ _.extend(record, change[2]);
+ }
+ turtles.push([1, record.id, record]);
+ }
+ obj.turtles = turtles;
+ },
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="turtles">' +
+ '<tree editable="bottom" default_order="turtle_int" limit="10">' +
+ '<field name="turtle_int" widget="handle"/>' +
+ '<field name="turtle_foo"/>' +
+ '<field name="turtle_qux" attrs="{\'readonly\': [(\'turtle_foo\', \'=\', False)]}"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+
+ assert.equal(form.$('.o_field_one2many td[class="o_data_cell"]').text(), "#20#21#22#23#24#25#26#27#28#29",
+ "should display the records in order");
+
+ 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');
+
+ // the domain fail if the widget does not use the allready loaded data.
+ form.$buttons.find('.o_form_button_cancel').click();
+
+ assert.equal(form.$('.o_field_one2many td[class="o_data_cell"]').text(), "blurp#21#22#23#24#25#26#27#28#29",
+ "should display the records in order with the changes");
+
+ $('.modal .modal-footer button:first').click();
+
+ assert.equal(form.$('.o_field_one2many td[class="o_data_cell"]').text(), "#20#21#22#23#24#25#26#27#28#29",
+ "should cancel changes and display the records in order");
+
+ form.$buttons.find('.o_form_button_edit').click();
+
+ // 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' }
+ );
+
+ assert.equal(form.$('.o_field_one2many td[class="o_data_cell"]').text(), "#20#30#31#32#33#34#35#36#37#38",
+ "should display the records in order after resequence (display record with turtle_int=0)");
+
+ // 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' }
+ );
+
+ assert.equal(form.$('.o_field_one2many td[class="o_data_cell"]').text(), "#20#39#40#41#42#43#44#45#46#47",
+ "should display the records in order after resequence (display record with turtle_int=0)");
+
+ form.$buttons.find('.o_form_button_cancel').click();
+
+ assert.equal(form.$('.o_field_one2many td[class="o_data_cell"]').text(), "#20#39#40#41#42#43#44#45#46#47",
+ "should display the records in order after resequence");
+
+ $('.modal .modal-footer button:first').click();
+
+ assert.equal(form.$('.o_field_one2many td[class="o_data_cell"]').text(), "#20#21#22#23#24#25#26#27#28#29",
+ "should cancel changes and display the records in order");
+
+ form.destroy();
+ });
+
+ QUnit.test('onchange followed by edition on the second page', function (assert) {
+ assert.expect(12);
+
+ var ids = [];
+ for (var i = 1; i < 85; i++) {
+ var id = 10 + i;
+ ids.push(id);
+ this.data.turtle.records.push({
+ id: id,
+ turtle_int: id / 3 | 0,
+ turtle_foo: "#" + i,
+ });
+ }
+ ids.splice(41, 0, 1, 2, 3);
+ this.data.partner.records[0].turtles = ids;
+ 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="top" default_order="turtle_int">' +
+ '<field name="turtle_int" widget="handle"/>' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_pager_next').click();
+
+ form.$('.o_field_one2many .o_list_view tbody tr:eq(1) td:first').click();
+ form.$('.o_field_one2many .o_list_view tbody tr:eq(1) input:first').val('value 1').trigger('input');
+ form.$('.o_field_one2many .o_list_view tbody tr:eq(2) td:first').click();
+ form.$('.o_field_one2many .o_list_view tbody tr:eq(2) input:first').val('value 2').trigger('input');
+
+ assert.strictEqual(form.$('.o_data_row').length, 40, "should display 40 records");
+ assert.strictEqual(form.$('.o_data_row:has(.o_data_cell:contains(#39))').index(), 0, "should display '#39' at the first line");
+
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ assert.strictEqual(form.$('.o_data_row').length, 40, "should display 39 records and the create line");
+ assert.strictEqual(form.$('.o_data_row:first .o_field_char').length, 1, "should display the create line in first position");
+ assert.strictEqual(form.$('.o_data_row:first .o_field_char').val(), "", "should an empty input");
+ assert.strictEqual(form.$('.o_data_row:has(.o_data_cell:contains(#39))').index(), 1, "should display '#39' at the second line");
+
+ form.$('.o_data_row input:first').val('value 3').trigger('input');
+
+ assert.strictEqual(form.$('.o_data_row:first .o_field_char').length, 1, "should display the create line in first position after onchange");
+ assert.strictEqual(form.$('.o_data_row:has(.o_data_cell:contains(#39))').index(), 1, "should display '#39' at the second line after onchange");
+
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ assert.strictEqual(form.$('.o_data_row').length, 40, "should display 39 records and the create line");
+ assert.strictEqual(form.$('.o_data_row:first .o_field_char').length, 1, "should display the create line in first position");
+ assert.strictEqual(form.$('.o_data_row:has(.o_data_cell:contains(value 3))').index(), 1, "should display the created line at the second position");
+ assert.strictEqual(form.$('.o_data_row:has(.o_data_cell:contains(#39))').index(), 2, "should display '#39' at the third line");
+
+ form.destroy();
+ });
+
+ QUnit.test('onchange followed by edition on the second page (part 2)', function (assert) {
+ assert.expect(8);
+
+ var ids = [];
+ for (var i = 1; i < 85; i++) {
+ var id = 10 + i;
+ ids.push(id);
+ this.data.turtle.records.push({
+ id: id,
+ turtle_int: id / 3 | 0,
+ turtle_foo: "#" + i,
+ });
+ }
+ ids.splice(41, 0, 1, 2, 3);
+ this.data.partner.records[0].turtles = ids;
+ this.data.partner.onchanges = {
+ turtles: function (obj) {
+ obj.turtles = [[5]].concat(obj.turtles);
+ },
+ };
+
+ // bottom order
+
+ 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,
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_pager_next').click();
+
+ form.$('.o_field_one2many .o_list_view tbody tr:eq(1) td:first').click();
+ form.$('.o_field_one2many .o_list_view tbody tr:eq(1) input:first').val('value 1').trigger('input');
+ form.$('.o_field_one2many .o_list_view tbody tr:eq(2) td:first').click();
+ form.$('.o_field_one2many .o_list_view tbody tr:eq(2) input:first').val('value 2').trigger('input');
+
+ assert.strictEqual(form.$('.o_data_row').length, 40, "should display 40 records");
+ assert.strictEqual(form.$('.o_data_row:has(.o_data_cell:contains(#77))').index(), 39, "should display '#77' at the last line");
+
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ assert.strictEqual(form.$('.o_data_row').length, 41, "should display 41 records and the create line");
+ assert.strictEqual(form.$('.o_data_row:has(.o_data_cell:contains(#76))').index(), 38, "should display '#76' at the penultimate line");
+ assert.strictEqual(form.$('.o_data_row:has(.o_field_char)').index(), 40, "should display the create line at the last position");
+
+ form.$('.o_data_row input:first').val('value 3').trigger('input');
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ assert.strictEqual(form.$('.o_data_row').length, 42, "should display 42 records and the create line");
+ assert.strictEqual(form.$('.o_data_row:has(.o_data_cell:contains(#76))').index(), 38, "should display '#76' at the penultimate line");
+ assert.strictEqual(form.$('.o_data_row:has(.o_field_char)').index(), 41, "should display the create line at the last position");
+
+ form.destroy();
+ });
+
+ QUnit.test('onchange returning a command 6 for an x2many', function (assert) {
+ assert.expect(2);
+
+ this.data.partner.onchanges = {
+ foo: function (obj) {
+ obj.turtles = [[6, false, [1, 2, 3]]];
+ },
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="foo"/>' +
+ '<field name="turtles">' +
+ '<tree>' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "there should be one record in the relation");
+
+ // change the value of foo to trigger the onchange
+ form.$('.o_field_widget[name=foo]').val('some value').trigger('input');
+
+ assert.strictEqual(form.$('.o_data_row').length, 3,
+ "there should be three records in the relation");
+
+ form.destroy();
+ });
+
+ QUnit.test('x2many fields inside x2manys are fetched after an onchange', function (assert) {
+ assert.expect(6);
+
+ this.data.turtle.records[0].partner_ids = [1];
+ this.data.partner.onchanges = {
+ foo: function (obj) {
+ obj.turtles = [[5], [4, 1], [4, 2], [4, 3]];
+ },
+ };
+
+ var checkRPC = false;
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="foo"/>' +
+ '<field name="turtles">' +
+ '<tree>' +
+ '<field name="turtle_foo"/>' +
+ '<field name="partner_ids" widget="many2many_tags"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (checkRPC && args.method === 'read' && args.model === 'partner') {
+ assert.deepEqual(args.args[1], ['display_name'],
+ "should only read the display_name for the m2m tags");
+ assert.deepEqual(args.args[0], [1],
+ "should only read the display_name of the unknown record");
+ }
+ return this._super.apply(this, arguments);
+ },
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "there should be one record in the relation");
+ assert.strictEqual(form.$('.o_data_row .o_field_widget[name=partner_ids]').text().replace(/\s/g, ''),
+ 'secondrecordaaa', "many2many_tags should be correctly displayed");
+
+ // change the value of foo to trigger the onchange
+ checkRPC = true; // enable flag to check read RPC for the m2m field
+ form.$('.o_field_widget[name=foo]').val('some value').trigger('input');
+
+ assert.strictEqual(form.$('.o_data_row').length, 3,
+ "there should be three records in the relation");
+ assert.strictEqual(form.$('.o_data_row:first .o_field_widget[name=partner_ids]').text().trim(),
+ 'first record', "many2many_tags should be correctly displayed");
+
+ form.destroy();
+ });
+
+ QUnit.test('reference fields inside x2manys are fetched after an onchange', function (assert) {
+ assert.expect(5);
+
+ this.data.turtle.records[1].turtle_ref = 'product,41';
+ this.data.partner.onchanges = {
+ foo: function (obj) {
+ obj.turtles = [[5], [4, 1], [4, 2], [4, 3]];
+ },
+ };
+
+ var checkRPC = false;
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="foo"/>' +
+ '<field name="turtles">' +
+ '<tree>' +
+ '<field name="turtle_foo"/>' +
+ '<field name="turtle_ref" class="ref_field"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (checkRPC && args.method === 'name_get') {
+ assert.deepEqual(args.args[0], [37],
+ "should only fetch the name_get of the unknown record");
+ }
+ return this._super.apply(this, arguments);
+ },
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "there should be one record in the relation");
+ assert.strictEqual(form.$('.ref_field').text().trim(), 'xpad',
+ "reference field should be correctly displayed");
+
+ // change the value of foo to trigger the onchange
+ checkRPC = true; // enable flag to check read RPC for reference field
+ form.$('.o_field_widget[name=foo]').val('some value').trigger('input');
+
+ assert.strictEqual(form.$('.o_data_row').length, 3,
+ "there should be three records in the relation");
+ assert.strictEqual(form.$('.ref_field').text().trim(), 'xpadxphone',
+ "reference fields should be correctly displayed");
+
+ form.destroy();
+ });
+
+ QUnit.test('onchange on one2many containing x2many in form view', function (assert) {
+ assert.expect(16);
+
+ this.data.partner.onchanges = {
+ foo: function (obj) {
+ obj.turtles = [[0, false, { turtle_foo: 'new record' }]];
+ },
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="foo"/>' +
+ '<field name="turtles">' +
+ '<tree>' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '<form>' +
+ '<field name="partner_ids">' +
+ '<tree editable="top">' +
+ '<field name="foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>' +
+ '</field>' +
+ '</form>',
+ archs: {
+ 'partner,false,list': '<tree><field name="foo"/></tree>',
+ 'partner,false,search': '<search></search>',
+ },
+ });
+
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "the onchange should have created one record in the relation");
+
+ // open the created o2m record in a form view, and add a m2m subrecord
+ // in its relation
+ form.$('.o_data_row').click();
+
+ assert.strictEqual($('.modal').length, 1, "should have opened a dialog");
+ assert.strictEqual($('.modal .o_data_row').length, 0,
+ "there should be no record in the one2many in the dialog");
+
+ // add a many2many subrecord
+ $('.modal .o_field_x2many_list_row_add a').click();
+
+ assert.strictEqual($('.modal').length, 2,
+ "should have opened a second dialog");
+
+ // select a many2many subrecord
+ $('.modal:nth(1) .o_list_view .o_data_cell:first').click();
+
+ assert.strictEqual($('.modal').length, 1,
+ "second dialog should be closed");
+ assert.strictEqual($('.modal .o_data_row').length, 1,
+ "there should be one record in the one2many in the dialog");
+ assert.notOk($('.modal .o_x2m_control_panel .o_cp_pager div').is(':visible'),
+ 'm2m pager should be hidden');
+
+ // click on 'Save & Close'
+ $('.modal-footer .btn-primary:first').click();
+
+ assert.strictEqual($('.modal').length, 0, "dialog should be closed");
+
+ // reopen o2m record, and another m2m subrecord in its relation, but
+ // discard the changes
+ form.$('.o_data_row').click();
+
+ assert.strictEqual($('.modal').length, 1, "should have opened a dialog");
+ assert.strictEqual($('.modal .o_data_row').length, 1,
+ "there should be one record in the one2many in the dialog");
+
+ // add another m2m subrecord
+ $('.modal .o_field_x2many_list_row_add a').click();
+
+ assert.strictEqual($('.modal').length, 2,
+ "should have opened a second dialog");
+
+ $('.modal:nth(1) .o_list_view .o_data_cell:first').click();
+
+ assert.strictEqual($('.modal').length, 1,
+ "second dialog should be closed");
+ assert.strictEqual($('.modal .o_data_row').length, 2,
+ "there should be two records in the one2many in the dialog");
+
+ // click on 'Discard'
+ $('.modal-footer .btn-secondary').click();
+
+ assert.strictEqual($('.modal').length, 0, "dialog should be closed");
+
+ // reopen o2m record to check that second changes have properly been discarded
+ form.$('.o_data_row').click();
+
+ assert.strictEqual($('.modal').length, 1, "should have opened a dialog");
+ assert.strictEqual($('.modal .o_data_row').length, 1,
+ "there should be one record in the one2many in the dialog");
+
+ form.destroy();
+ });
+
+ QUnit.test('embedded one2many with handle widget with minimum setValue calls', function (assert) {
+ var done = assert.async();
+ assert.expect(20);
+
+
+ this.data.turtle.records[0].turtle_int = 6;
+ this.data.turtle.records.push({
+ id: 4,
+ turtle_int: 20,
+ turtle_foo: "a1",
+ }, {
+ id: 5,
+ turtle_int: 9,
+ turtle_foo: "a2",
+ }, {
+ id: 6,
+ turtle_int: 2,
+ turtle_foo: "a3",
+ }, {
+ id: 7,
+ turtle_int: 11,
+ turtle_foo: "a4",
+ });
+ this.data.partner.records[0].turtles = [1, 2, 3, 4, 5, 6, 7];
+
+ 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,
+ });
+
+ testUtils.intercept(form, "field_changed", function (event) {
+ assert.step(form.model.get(event.data.changes.turtles.id).res_id);
+ }, true);
+
+ form.$buttons.find('.o_form_button_edit').click();
+
+ var steps = [];
+ var positions = [
+ [6, 0, 'top', [3, 6, 1, 2, 5, 7, 4]], // move the last to the first line
+ [5, 1, 'top', [7, 6, 1, 2, 5]], // move the penultimate to the second line
+ [2, 5, 'center', [1, 2, 5, 6]], // move the third to the penultimate line
+ ];
+ function dragAndDrop() {
+ var pos = positions.shift();
+
+ testUtils.dragAndDrop(
+ form.$('.ui-sortable-handle').eq(pos[0]),
+ form.$('tbody tr').eq(pos[1]),
+ { position: pos[2] }
+ );
+
+ steps = steps.concat(pos[3]);
+ assert.verifySteps(steps,
+ "sequences values should be apply from the begin index to the drop index");
+
+ if (positions.length) {
+
+ setTimeout(dragAndDrop, 10);
+
+ } else {
+
+ assert.deepEqual(_.pluck(form.model.get(form.handle).data.turtles.data, 'data'), [
+ { id: 3, turtle_foo: "kawa", turtle_int: 2 },
+ { id: 7, turtle_foo: "a4", turtle_int: 3 },
+ { id: 1, turtle_foo: "yop", turtle_int: 4 },
+ { id: 2, turtle_foo: "blip", turtle_int: 5 },
+ { id: 5, turtle_foo: "a2", turtle_int: 6 },
+ { id: 6, turtle_foo: "a3", turtle_int: 7 },
+ { id: 4, turtle_foo: "a1", turtle_int: 8 }
+ ], "sequences must be apply correctly");
+
+ form.destroy();
+ done();
+ }
+ }
+
+ dragAndDrop();
+ });
+
+ QUnit.test('embedded one2many (editable list) with handle widget', function (assert) {
+ assert.expect(8);
+
+ this.data.partner.records[0].p = [1, 2, 4];
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<notebook>' +
+ '<page string="P page">' +
+ '<field name="p">' +
+ '<tree editable="top">' +
+ '<field name="int_field" widget="handle"/>' +
+ '<field name="foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</page>' +
+ '</notebook>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ testUtils.intercept(form, "field_changed", function (event) {
+ assert.step(event.data.changes.p.data.int_field.toString());
+ }, true);
+
+ assert.strictEqual(form.$('td.o_data_cell:not(.o_handle_cell)').text(), "My little Foo Valueblipyop",
+ "should have the 3 rows in the correct order");
+
+ form.$buttons.find('.o_form_button_edit').click();
+ assert.strictEqual(form.$('td.o_data_cell:not(.o_handle_cell)').text(), "My little Foo Valueblipyop",
+ "should still 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.verifySteps(["0", "1"],
+ "sequences values should be incremental starting from the previous minimum one");
+
+ assert.strictEqual(form.$('td.o_data_cell:not(.o_handle_cell)').text(), "blipMy little Foo Valueyop",
+ "should have the 3 rows in the new order");
+
+ form.$('tbody tr:first td:first').click();
+
+ assert.strictEqual(form.$('tbody tr:first td.o_data_cell:not(.o_handle_cell) input').val(), "blip",
+ "should edit the correct row");
+
+ form.$buttons.find('.o_form_button_save').click();
+ assert.strictEqual(form.$('td.o_data_cell:not(.o_handle_cell)').text(), "blipMy little Foo Valueyop",
+ "should still have the 3 rows in the new order");
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many field when using the pager', function (assert) {
+ assert.expect(13);
+
+ var ids = [];
+ for (var i = 0; i < 45; i++) {
+ var id = 10 + i;
+ ids.push(id);
+ this.data.partner.records.push({
+ id: id,
+ display_name: "relational record " + id,
+ });
+ }
+ this.data.partner.records[0].p = ids.slice(0, 42);
+ this.data.partner.records[1].p = ids.slice(42);
+
+ var count = 0;
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<kanban>' +
+ '<field name="display_name"/>' +
+ '<templates>' +
+ '<t t-name="kanban-box">' +
+ '<div><t t-esc="record.display_name"/></div>' +
+ '</t>' +
+ '</templates>' +
+ '</kanban>' +
+ '</field>' +
+ '</form>',
+ viewOptions: {
+ ids: [1, 2],
+ index: 0,
+ },
+ mockRPC: function () {
+ count++;
+ return this._super.apply(this, arguments);
+ },
+ res_id: 1,
+ });
+
+ // we are on record 1, which has 90 related record (first 40 should be
+ // displayed), 2 RPCs (read) should have been done, one on the main record
+ // and one for the O2M
+ assert.strictEqual(count, 2, 'two RPCs should have been done');
+ assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40,
+ 'one2many kanban should contain 40 cards for record 1');
+
+ // move to record 2, which has 3 related records (and shouldn't contain the
+ // related records of record 1 anymore). Two additional RPCs should have
+ // been done
+ form.pager.next();
+ assert.strictEqual(count, 4, 'two RPCs should have been done');
+ assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 3,
+ 'one2many kanban should contain 3 cards for record 2');
+
+ // move back to record 1, which should contain again its first 40 related
+ // records
+ form.pager.previous();
+ assert.strictEqual(count, 6, 'two RPCs should have been done');
+ assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40,
+ 'one2many kanban should contain 40 cards for record 1');
+
+ // move to the second page of the o2m: 1 RPC should have been done to fetch
+ // the 2 subrecords of page 2, and those records should now be displayed
+ form.$('.o_x2m_control_panel .o_pager_next').click();
+ assert.strictEqual(count, 7, 'one RPC should have been done');
+ assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 2,
+ 'one2many kanban should contain 2 cards for record 1 at page 2');
+
+ // move to record 2 again and check that everything is correctly updated
+ form.pager.next();
+ assert.strictEqual(count, 9, 'two RPCs should have been done');
+ assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 3,
+ 'one2many kanban should contain 3 cards for record 2');
+
+ // move back to record 1 and move to page 2 again: all data should have
+ // been correctly reloaded
+ form.pager.previous();
+ assert.strictEqual(count, 11, 'two RPCs should have been done');
+ form.$('.o_x2m_control_panel .o_pager_next').click();
+ assert.strictEqual(count, 12, 'one RPC should have been done');
+ assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 2,
+ 'one2many kanban should contain 2 cards for record 1 at page 2');
+ form.destroy();
+ });
+
+ QUnit.test('edition of one2many field with pager', function (assert) {
+ assert.expect(31);
+
+ var ids = [];
+ for (var i = 0; i < 45; i++) {
+ var id = 10 + i;
+ ids.push(id);
+ this.data.partner.records.push({
+ id: id,
+ display_name: "relational record " + id,
+ });
+ }
+ this.data.partner.records[0].p = ids;
+
+ var saveCount = 0;
+ var checkRead = false;
+ var readIDs;
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<kanban>' +
+ '<field name="display_name"/>' +
+ '<templates>' +
+ '<t t-name="kanban-box">' +
+ '<div class="oe_kanban_global_click">' +
+ '<a t-if="!read_only_mode" type="delete" class="fa fa-times float-right delete_icon"/>' +
+ '<span><t t-esc="record.display_name.value"/></span>' +
+ '</div>' +
+ '</t>' +
+ '</templates>' +
+ '</kanban>' +
+ '</field>' +
+ '</form>',
+ archs: {
+ 'partner,false,form': '<form><field name="display_name"/></form>',
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'read' && checkRead) {
+ readIDs = args.args[0];
+ checkRead = false;
+ }
+ if (args.method === 'write') {
+ saveCount++;
+ var nbCommands = args.args[1].p.length;
+ var nbLinkCommands = _.filter(args.args[1].p, function (command) {
+ return command[0] === 4;
+ }).length;
+ switch (saveCount) {
+ case 1:
+ assert.strictEqual(nbCommands, 46,
+ "should send 46 commands (one for each record)");
+ assert.strictEqual(nbLinkCommands, 45,
+ "should send a LINK_TO command for each existing record");
+ assert.deepEqual(args.args[1].p[45], [0, args.args[1].p[45][1], {
+ display_name: 'new record',
+ }], "should sent a CREATE command for the new record");
+ break;
+ case 2:
+ assert.strictEqual(nbCommands, 46,
+ "should send 46 commands");
+ assert.strictEqual(nbLinkCommands, 45,
+ "should send a LINK_TO command for each existing record");
+ assert.deepEqual(args.args[1].p[45], [2, 10, false],
+ "should sent a DELETE command for the deleted record");
+ break;
+ case 3:
+ assert.strictEqual(nbCommands, 47,
+ "should send 47 commands");
+ assert.strictEqual(nbLinkCommands, 43,
+ "should send a LINK_TO command for each existing record");
+ assert.deepEqual(args.args[1].p[43],
+ [0, args.args[1].p[43][1], { display_name: 'new record page 1' }],
+ "should sent correct CREATE command");
+ assert.deepEqual(args.args[1].p[44],
+ [0, args.args[1].p[44][1], { display_name: 'new record page 2' }],
+ "should sent correct CREATE command");
+ assert.deepEqual(args.args[1].p[45],
+ [2, 11, false],
+ "should sent correct DELETE command");
+ assert.deepEqual(args.args[1].p[46],
+ [2, 52, false],
+ "should sent correct DELETE command");
+ break;
+ }
+ }
+ return this._super.apply(this, arguments);
+ },
+ res_id: 1,
+ });
+
+ assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40,
+ 'there should be 40 records on page 1');
+ assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(),
+ '1-40 / 45', "pager range should be correct");
+
+ // add a record on page one
+ checkRead = true;
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o-kanban-button-new').click();
+ $('.modal input').val('new record').trigger('input');
+ $('.modal .modal-footer .btn-primary:first').click(); // save and close
+ // checks
+ assert.strictEqual(readIDs, undefined, "should not have read any record");
+ assert.strictEqual(form.$('span:contains(new record)').length, 0,
+ "new record should be on page 2");
+ assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40,
+ 'there should be 40 records on page 1');
+ assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(),
+ '1-40 / 46', "pager range should be correct");
+ assert.strictEqual(form.$('.o_kanban_record:first span:contains(new record)').length,
+ 0, 'new record should not be on page 1');
+ // save
+ form.$buttons.find('.o_form_button_save').click();
+
+ // delete a record on page one
+ checkRead = true;
+ form.$buttons.find('.o_form_button_edit').click();
+ assert.strictEqual(form.$('.o_kanban_record:first span:contains(relational record 10)').length,
+ 1, 'first record should be the one with id 10 (next checks rely on that)');
+ form.$('.delete_icon:first').click();
+ // checks
+ assert.deepEqual(readIDs, [50],
+ "should have read a record (to display 40 records on page 1)");
+ assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40,
+ 'there should be 40 records on page 1');
+ assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(),
+ '1-40 / 45', "pager range should be correct");
+ // save
+ form.$buttons.find('.o_form_button_save').click();
+
+ // add and delete records in both pages
+ form.$buttons.find('.o_form_button_edit').click();
+ checkRead = true;
+ readIDs = undefined;
+ // add and delete a record in page 1
+ form.$('.o-kanban-button-new').click();
+ $('.modal input').val('new record page 1').trigger('input');
+ $('.modal .modal-footer .btn-primary:first').click(); // save and close
+ assert.strictEqual(form.$('.o_kanban_record:first span:contains(relational record 11)').length,
+ 1, 'first record should be the one with id 11 (next checks rely on that)');
+ form.$('.delete_icon:first').click();
+ assert.deepEqual(readIDs, [51],
+ "should have read a record (to display 40 records on page 1)");
+ // add and delete a record in page 2
+ form.$('.o_x2m_control_panel .o_pager_next').click();
+ assert.strictEqual(form.$('.o_kanban_record:first span:contains(relational record 52)').length,
+ 1, 'first record should be the one with id 52 (next checks rely on that)');
+ checkRead = true;
+ readIDs = undefined;
+ form.$('.delete_icon:first').click();
+ form.$('.o-kanban-button-new').click();
+ $('.modal input').val('new record page 2').trigger('input');
+ $('.modal .modal-footer .btn-primary:first').click(); // save and close
+ assert.strictEqual(readIDs, undefined, "should not have read any record");
+ // checks
+ assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 5,
+ 'there should be 5 records on page 2');
+ assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(),
+ '41-45 / 45', "pager range should be correct");
+ assert.strictEqual(form.$('.o_kanban_record span:contains(new record page 1)').length,
+ 1, 'new records should be on page 2');
+ assert.strictEqual(form.$('.o_kanban_record span:contains(new record page 2)').length,
+ 1, 'new records should be on page 2');
+ // save
+ form.$buttons.find('.o_form_button_save').click();
+
+ form.destroy();
+ });
+
+ QUnit.test('edition of one2many field, with onchange and not inline sub view', function (assert) {
+ assert.expect(2);
+
+ this.data.turtle.onchanges.turtle_int = function (obj) {
+ obj.turtle_foo = String(obj.turtle_int);
+ };
+ this.data.partner.onchanges.turtles = function () { };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles"/>' +
+ '</form>',
+ archs: {
+ 'turtle,false,list': '<tree><field name="turtle_foo"/></tree>',
+ 'turtle,false,form': '<form><group><field name="turtle_foo"/><field name="turtle_int"/></group></form>',
+ },
+ mockRPC: function (route, args) {
+ return this._super.apply(this, arguments);
+ },
+ res_id: 1,
+ });
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ $('input[name="turtle_int"]').val('5').trigger('input');
+ $('.modal-footer button.btn-primary').first().click();
+ assert.strictEqual(form.$('tbody tr:eq(1) td.o_data_cell').text(), '5',
+ 'should display 5 in the foo field');
+ form.$('tbody tr:eq(1) td.o_data_cell').click();
+
+ $('input[name="turtle_int"]').val('3').trigger('input');
+ $('.modal-footer button.btn-primary').first().click();
+ assert.strictEqual(form.$('tbody tr:eq(1) td.o_data_cell').text(), '3',
+ 'should now display 3 in the foo field');
+ form.destroy();
+ });
+
+ QUnit.test('sorting one2many fields', function (assert) {
+ assert.expect(4);
+
+ this.data.partner.fields.foo.sortable = true;
+ this.data.partner.records.push({ id: 23, foo: "abc" });
+ this.data.partner.records.push({ id: 24, foo: "xyz" });
+ this.data.partner.records.push({ id: 25, foo: "def" });
+ this.data.partner.records[0].p = [23, 24, 25];
+
+ var rpcCount = 0;
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function () {
+ rpcCount++;
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ rpcCount = 0;
+ assert.ok(form.$('table tbody tr:eq(2) td:contains(def)').length,
+ "the 3rd record is the one with 'def' value");
+ form.renderer._render = function () {
+ throw "should not render the whole form";
+ };
+
+ form.$('table thead th:contains(Foo)').click();
+ assert.strictEqual(rpcCount, 0,
+ 'sort should be in memory, no extra RPCs should have been done');
+ assert.ok(form.$('table tbody tr:eq(2) td:contains(xyz)').length,
+ "the 3rd record is the one with 'xyz' value");
+
+ form.$('table thead th:contains(Foo)').click();
+ assert.ok(form.$('table tbody tr:eq(2) td:contains(abc)').length,
+ "the 3rd record is the one with 'abc' value");
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many list field edition', function (assert) {
+ assert.expect(6);
+
+ this.data.partner.records.push({
+ id: 3,
+ display_name: "relational record 1",
+ });
+ this.data.partner.records[1].p = [3];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<tree editable="top">' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 2,
+ });
+
+ // edit the first line of the o2m
+ assert.strictEqual(form.$('.o_field_one2many tbody td').first().text(), 'relational record 1',
+ "display name of first record in o2m list should be 'relational record 1'");
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_one2many tbody td').first().click();
+ assert.ok(form.$('.o_field_one2many tbody td').first().parent().hasClass('o_selected_row'),
+ "first row of o2m should be in edition");
+ form.$('.o_field_one2many tbody td').first().find('input').val("new value").trigger('input');
+ assert.ok(form.$('.o_field_one2many tbody td').first().parent().hasClass('o_selected_row'),
+ "first row of o2m should still be in edition");
+
+ // // leave o2m edition
+ form.$el.click();
+ assert.ok(!form.$('.o_field_one2many tbody td').first().parent().hasClass('o_selected_row'),
+ "first row of o2m should be readonly again");
+
+ // discard changes
+ form.$buttons.find('.o_form_button_cancel').click();
+ assert.strictEqual(form.$('.o_field_one2many tbody td').first().text(), 'new value',
+ "changes shouldn't have been discarded yet, waiting for user confirmation");
+ $('.modal .modal-footer .btn-primary').click();
+ assert.strictEqual(form.$('.o_field_one2many tbody td').first().text(), 'relational record 1',
+ "display name of first record in o2m list should be 'relational record 1'");
+
+ // edit again and save
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_one2many tbody td').first().click();
+ form.$('.o_field_one2many tbody td').first().find('input').val("new value").trigger('input');
+ form.$el.click();
+ form.$buttons.find('.o_form_button_save').click();
+ // FIXME: this next test doesn't pass as the save of updates of
+ // relational data is temporarily disabled
+ // assert.strictEqual(form.$('.o_field_one2many tbody td').first().text(), 'new value',
+ // "display name of first record in o2m list should be 'new value'");
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many list: create action disabled', function (assert) {
+ assert.expect(2);
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<tree create="0">' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ assert.ok(!form.$('.o_field_x2many_list_row_add').length,
+ '"Add an item" link should not be available in readonly');
+
+ form.$buttons.find('.o_form_button_edit').click();
+
+ assert.ok(!form.$('.o_field_x2many_list_row_add').length,
+ '"Add an item" link should not be available in readonly');
+ form.destroy();
+ });
+
+ QUnit.test('one2many list: unlink two records', function (assert) {
+ assert.expect(9);
+ this.data.partner.records[0].p = [1, 2, 4];
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p" widget="many2many">' +
+ '<tree>' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/call_kw/partner/write') {
+ var commands = args.args[1].p;
+ assert.strictEqual(commands.length, 3,
+ 'should have generated three commands');
+ assert.ok(commands[0][0] === 4 && commands[0][1] === 4,
+ 'should have generated the command 4 (LINK_TO) with id 4');
+ assert.ok(commands[1][0] === 3 && commands[1][1] === 1,
+ 'should have generated the command 3 (UNLINK) with id 1');
+ assert.ok(commands[2][0] === 3 && commands[2][1] === 2,
+ 'should have generated the command 3 (UNLINK) with id 2');
+ }
+ return this._super.apply(this, arguments);
+ },
+ archs: {
+ 'partner,false,form':
+ '<form string="Partner"><field name="display_name"/></form>',
+ },
+ });
+ form.$buttons.find('.o_form_button_edit').click();
+
+ assert.strictEqual(form.$('td.o_list_record_remove button').length, 3,
+ "should have 3 remove buttons");
+
+ assert.ok(form.$('td.o_list_record_remove button').first().hasClass('fa fa-times'),
+ "should have X icons to remove (unlink) records");
+
+ form.$('td.o_list_record_remove button').first().click();
+
+ assert.strictEqual(form.$('td.o_list_record_remove button').length, 2,
+ "should have 2 remove buttons (a record is supposed to have been unlinked)");
+
+ form.$('tr.o_data_row').first().click();
+ assert.strictEqual($('.modal .modal-footer .o_btn_remove').length, 1,
+ 'there should be a modal having Remove Button');
+ $('.modal .modal-footer .o_btn_remove').click();
+
+ assert.strictEqual(form.$('td.o_list_record_remove button').length, 1,
+ "should have 1 delete button (another record is supposed to have been unlinked)");
+
+ // save and check that the correct command has been generated
+ form.$buttons.find('.o_form_button_save').click();
+ form.destroy();
+ });
+
+ QUnit.test('one2many list: deleting two records', function (assert) {
+ assert.expect(9);
+ this.data.partner.records[0].p = [1, 2, 4];
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/call_kw/partner/write') {
+ var commands = args.args[1].p;
+ assert.strictEqual(commands.length, 3,
+ 'should have generated three commands');
+ assert.ok(commands[0][0] === 4 && commands[0][1] === 4,
+ 'should have generated the command 4 (LINK_TO) with id 4');
+ assert.ok(commands[1][0] === 2 && commands[1][1] === 1,
+ 'should have generated the command 2 (DELETE) with id 1');
+ assert.ok(commands[2][0] === 2 && commands[2][1] === 2,
+ 'should have generated the command 2 (DELETE) with id 2');
+ }
+ return this._super.apply(this, arguments);
+ },
+ archs: {
+ 'partner,false,form':
+ '<form string="Partner"><field name="display_name"/></form>',
+ },
+ });
+ form.$buttons.find('.o_form_button_edit').click();
+
+ assert.strictEqual(form.$('td.o_list_record_remove button').length, 3,
+ "should have 3 remove buttons");
+
+ assert.ok(form.$('td.o_list_record_remove button').first().hasClass('fa fa-trash-o'),
+ "should have trash bin icons to remove (delete) records");
+
+ form.$('td.o_list_record_remove button').first().click();
+
+ assert.strictEqual(form.$('td.o_list_record_remove button').length, 2,
+ "should have 2 remove buttons (a record is supposed to have been deleted)");
+
+ form.$('tr.o_data_row').first().click();
+ assert.strictEqual($('.modal .modal-footer .o_btn_remove').length, 1,
+ 'there should be a modal having Remove Button');
+ $('.modal .modal-footer .o_btn_remove').click();
+
+ assert.strictEqual(form.$('td.o_list_record_remove button').length, 1,
+ "should have 1 remove button (another record is supposed to have been deleted)");
+
+ // save and check that the correct command has been generated
+ form.$buttons.find('.o_form_button_save').click();
+
+ // FIXME: it would be nice to test that the view is re-rendered correctly,
+ // but as the relational data isn't re-fetched, the rendering is ok even
+ // if the changes haven't been saved
+ form.destroy();
+ });
+
+ QUnit.test('one2many kanban: edition', function (assert) {
+ assert.expect(17);
+
+ this.data.partner.records[0].p = [2];
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<kanban>' +
+ // color will be in the kanban but not in the form
+ '<field name="color"/>' +
+ '<field name="display_name"/>' +
+ '<templates>' +
+ '<t t-name="kanban-box">' +
+ '<div class="oe_kanban_global_click">' +
+ '<a t-if="!read_only_mode" type="delete" class="fa fa-times float-right delete_icon"/>' +
+ '<span><t t-esc="record.display_name.value"/></span>' +
+ '<span><t t-esc="record.color.value"/></span>' +
+ '</div>' +
+ '</t>' +
+ '</templates>' +
+ '</kanban>' +
+ '<form string="Partners">' +
+ '<field name="display_name"/>' +
+ // foo will be in the form but not in the kanban
+ '<field name="foo"/>' +
+ '</form>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ assert.ok(!form.$('.o_kanban_view .delete_icon').length,
+ 'delete icon should not be visible in readonly');
+ assert.ok(!form.$('.o_field_one2many .o-kanban-button-new').length,
+ '"Create" button should not be visible in readonly');
+
+ form.$buttons.find('.o_form_button_edit').click();
+
+ assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 1,
+ 'should contain 1 record');
+ assert.strictEqual(form.$('.o_kanban_record span:first').text(), 'second record',
+ 'display_name of subrecord should be the one in DB');
+ assert.strictEqual(form.$('.o_kanban_record span:nth(1)').text(), 'Red',
+ 'color of subrecord should be the one in DB');
+ assert.ok(form.$('.o_kanban_view .delete_icon').length,
+ 'delete icon should be visible in edit');
+ assert.ok(form.$('.o_field_one2many .o-kanban-button-new').length,
+ '"Create" button should be visible in edit');
+ assert.ok(form.$('.o_field_one2many .o-kanban-button-new').hasClass('btn-secondary'),
+ "'Create' button should have className 'btn-secondary'");
+ assert.strictEqual(form.$('.o_field_one2many .o-kanban-button-new').text().trim(), "Add",
+ 'Create button should have "Add" label');
+
+ // edit existing subrecord
+ form.$('.oe_kanban_global_click').click();
+
+ $('.modal .o_form_view input').val('new name').trigger('input');
+ $('.modal .modal-footer .btn-primary').click(); // save
+ assert.strictEqual(form.$('.o_kanban_record span:first').text(), 'new name',
+ 'value of subrecord should have been updated');
+
+ // create a new subrecord
+ form.$('.o-kanban-button-new').click();
+ $('.modal .o_form_view input').val('new subrecord 1').trigger('input');
+ $('.modal .modal-footer .btn-primary').click(); // save and close
+ assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 2,
+ 'should contain 2 records');
+ assert.strictEqual(form.$('.o_kanban_record:nth(1) span').text(), 'new subrecord 1',
+ 'value of newly created subrecord should be "new subrecord 1"');
+
+ // create two new subrecords
+ form.$('.o-kanban-button-new').click();
+ $('.modal .o_form_view input').val('new subrecord 2').trigger('input');
+ $('.modal .modal-footer .btn-primary:nth(1)').click(); // save and new
+ $('.modal .o_form_view input').val('new subrecord 3').trigger('input');
+ $('.modal .modal-footer .btn-primary').click(); // save and close
+ assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 4,
+ 'should contain 4 records');
+
+ // delete subrecords
+ form.$('.oe_kanban_global_click').first().click();
+ assert.strictEqual($('.modal .modal-footer .o_btn_remove').length, 1,
+ 'There should be a modal having Remove Button');
+ $('.modal .modal-footer .o_btn_remove').click(); // Click Remove Button of Formview Dialog
+ assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 3,
+ 'should contain 3 records');
+ form.$('.o_kanban_view .delete_icon:first()').click();
+ form.$('.o_kanban_view .delete_icon:first()').click();
+ assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 1,
+ 'should contain 1 records');
+ assert.strictEqual(form.$('.o_kanban_record span:first').text(), 'new subrecord 3',
+ 'the remaining subrecord should be "new subrecord 3"');
+ form.destroy();
+ });
+
+ QUnit.test('one2many kanban (editable): properly handle create_text node option', function (assert) {
+ assert.expect(1);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles" options="{\'create_text\': \'Add turtle\'}" mode="kanban">' +
+ '<kanban>' +
+ '<templates>' +
+ '<t t-name="kanban-box">' +
+ '<div class="oe_kanban_details">' +
+ '<field name="display_name"/>' +
+ '</div>' +
+ '</t>' +
+ '</templates>' +
+ '</kanban>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ assert.strictEqual(form.$('.o_field_one2many[name="turtles"] .o-kanban-button-new').text().trim(),
+ "Add turtle", "In O2M Kanban, Add button should have 'Add turtle' label");
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many kanban: create action disabled', function (assert) {
+ assert.expect(3);
+
+ this.data.partner.records[0].p = [4];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<kanban create="0">' +
+ '<field name="display_name"/>' +
+ '<templates>' +
+ '<t t-name="kanban-box">' +
+ '<div class="oe_kanban_global_click">' +
+ '<a t-if="!read_only_mode" type="delete" class="fa fa-times float-right delete_icon"/>' +
+ '<span><t t-esc="record.display_name.value"/></span>' +
+ '</div>' +
+ '</t>' +
+ '</templates>' +
+ '</kanban>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ assert.ok(!form.$('.o-kanban-button-new').length,
+ '"Add" button should not be available in readonly');
+
+ form.$buttons.find('.o_form_button_edit').click();
+
+ assert.ok(!form.$('.o-kanban-button-new').length,
+ '"Add" button should not be available in edit');
+ assert.ok(form.$('.o_kanban_view .delete_icon').length,
+ 'delete icon should be visible in edit');
+ form.destroy();
+ });
+
+ QUnit.test('editable one2many list, pager is updated', function (assert) {
+ assert.expect(1);
+
+ this.data.turtle.records.push({ id: 4, turtle_foo: 'stephen hawking' });
+ this.data.partner.records[0].turtles = [1, 2, 3, 4];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree editable="bottom" limit="3">' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ // add a record, then click in form view to confirm it
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$el.click();
+
+ assert.strictEqual(form.$('.o_cp_pager').text().trim(), '1-3 / 5',
+ "pager should display the correct total");
+ form.destroy();
+ });
+
+ QUnit.test('one2many list (non editable): edition', function (assert) {
+ assert.expect(12);
+
+ var nbWrite = 0;
+ this.data.partner.records[0].p = [2, 4];
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="display_name"/><field name="qux"/>' +
+ '</tree>' +
+ '<form string="Partners">' +
+ '<field name="display_name"/>' +
+ '</form>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ nbWrite++;
+ assert.deepEqual(args.args[1], {
+ p: [[1, 2, { display_name: 'new name' }], [2, 4, false]]
+ }, "should have sent the correct commands");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.ok(!form.$('.o_list_record_remove').length,
+ 'remove icon should not be visible in readonly');
+ assert.ok(!form.$('.o_field_x2many_list_row_add').length,
+ '"Add an item" should not be visible in readonly');
+
+ form.$buttons.find('.o_form_button_edit').click();
+
+ assert.strictEqual(form.$('.o_list_view td.o_list_number').length, 2,
+ 'should contain 2 records');
+ assert.strictEqual(form.$('.o_list_view tbody td:first()').text(), 'second record',
+ 'display_name of first subrecord should be the one in DB');
+ assert.ok(form.$('.o_list_record_remove').length,
+ 'remove icon should be visible in edit');
+ assert.ok(form.$('.o_field_x2many_list_row_add').length,
+ '"Add an item" should not visible in edit');
+
+ // edit existing subrecord
+ form.$('.o_list_view tbody tr:first() td:eq(1)').click();
+
+ $('.modal .o_form_view input').val('new name').trigger('input');
+ $('.modal .modal-footer .btn-primary').click(); // save
+ assert.strictEqual(form.$('.o_list_view tbody td:first()').text(), 'new name',
+ 'value of subrecord should have been updated');
+ assert.strictEqual(nbWrite, 0, "should not have write anything in DB");
+
+ // create new subrecords
+ // TODO when 'Add an item' will be implemented
+
+ // remove subrecords
+ form.$('.o_list_record_remove:nth(1)').click();
+ assert.strictEqual(form.$('.o_list_view td.o_list_number').length, 1,
+ 'should contain 1 subrecord');
+ assert.strictEqual(form.$('.o_list_view tbody td:first()').text(), 'new name',
+ 'the remaining subrecord should be "new name"');
+
+ form.$buttons.find('.o_form_button_save').click(); // save the record
+ assert.strictEqual(nbWrite, 1, "should have write the changes in DB");
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many list (editable): edition', function (assert) {
+ assert.expect(7);
+
+ this.data.partner.records[0].p = [2, 4];
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<tree editable="top">' +
+ '<field name="display_name"/><field name="qux"/>' +
+ '</tree>' +
+ '<form string="Partners">' +
+ '<field name="display_name"/>' +
+ '</form>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ assert.ok(!form.$('.o_field_x2many_list_row_add').length,
+ '"Add an item" link should not be available in readonly');
+
+ form.$('.o_list_view tbody td:first()').click();
+ assert.ok($('.modal .o_form_readonly').length,
+ 'in readonly, clicking on a subrecord should open it in readonly in a dialog');
+ $('.modal .o_form_button_cancel').click(); // close the dialog
+
+ form.$buttons.find('.o_form_button_edit').click();
+
+ assert.ok(form.$('.o_field_x2many_list_row_add').length,
+ '"Add an item" link should be available in edit');
+
+ // edit existing subrecord
+ form.$('.o_list_view tbody td:first()').click();
+ assert.strictEqual($('.modal').length, 0,
+ 'in edit, clicking on a subrecord should not open a dialog');
+ assert.ok(form.$('.o_list_view tbody tr:first()').hasClass('o_selected_row'),
+ 'first row should be in edition');
+ form.$('.o_list_view input:first()').val('new name').trigger('input');
+
+ form.$('.o_list_view tbody tr:nth(1) td:first').click(); // click on second record to validate the first one
+ assert.ok(!form.$('.o_list_view tbody tr:first').hasClass('o_selected_row'),
+ 'first row should not be in edition anymore');
+ assert.strictEqual(form.$('.o_list_view tbody td:first').text(), 'new name',
+ 'value of subrecord should have been updated');
+
+ // create new subrecords
+ // TODO when 'Add an item' will be implemented
+ form.destroy();
+ });
+
+ QUnit.test('one2many list (editable): edition, part 2', function (assert) {
+ assert.expect(8);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<tree editable="top">' +
+ '<field name="foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ assert.strictEqual(args.args[1].p[0][0], 0,
+ "should send a 0 command for field p");
+ assert.strictEqual(args.args[1].p[1][0], 0,
+ "should send a second 0 command for field p");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // edit mode, then click on Add an item and enter a value
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('.o_selected_row > td input').val('kartoffel').trigger('input');
+
+ // click again on Add an item
+ form.$('.o_field_x2many_list_row_add a').click();
+ assert.strictEqual(form.$('td:contains(kartoffel)').length, 1,
+ "should have one td with the new value");
+ assert.strictEqual(form.$('.o_selected_row > td input').length, 1,
+ "should have one other new td");
+ assert.strictEqual(form.$('tr.o_data_row').length, 2, "should have 2 data rows");
+
+ // enter another value and save
+ form.$('.o_selected_row > td input').val('gemuse').trigger('input');
+ form.$buttons.find('.o_form_button_save').click();
+ assert.strictEqual(form.$('tr.o_data_row').length, 2, "should have 2 data rows");
+ assert.strictEqual(form.$('td:contains(kartoffel)').length, 1,
+ "should have one td with the new value");
+ assert.strictEqual(form.$('td:contains(gemuse)').length, 1,
+ "should have one td with the new value");
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many list (editable): edition, part 3', function (assert) {
+ assert.expect(3);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group>' +
+ '<field name="turtles">' +
+ '<tree editable="top">' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ // edit mode, then click on Add an item 2 times
+ assert.strictEqual(form.$('tr.o_data_row').length, 1,
+ "should have 1 data rows");
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ assert.strictEqual(form.$('tr.o_data_row').length, 3,
+ "should have 3 data rows");
+
+ // cancel the edition
+ form.$buttons.find('.o_form_button_cancel').click();
+ $('.modal-footer button.btn-primary').first().click();
+ assert.strictEqual(form.$('tr.o_data_row').length, 1,
+ "should have 1 data rows");
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many list (editable): edition, part 4', function (assert) {
+ assert.expect(3);
+ var i = 0;
+
+ this.data.turtle.onchanges = {
+ turtle_trululu: function (obj) {
+ if (i) {
+ obj.turtle_description = "Some Description";
+ }
+ i++;
+ },
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group>' +
+ '<field name="turtles">' +
+ '<tree editable="top">' +
+ '<field name="turtle_trululu"/>' +
+ '<field name="turtle_description"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</form>',
+ res_id: 2,
+ });
+
+ // edit mode, then click on Add an item
+ assert.strictEqual(form.$('tr.o_data_row').length, 0,
+ "should have 0 data rows");
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ assert.strictEqual(form.$('textarea').val(), "",
+ "field turtle_description should be empty");
+
+ // add a value in the turtle_trululu field to trigger an onchange
+ var $dropdown = form.$('.o_field_many2one[name=turtle_trululu] input')
+ .autocomplete('widget');
+ form.$('.o_field_many2one[name=turtle_trululu] input').click();
+ $dropdown.find('a:contains(first record)').mouseenter().click();
+ assert.strictEqual(form.$('textarea').val(), "Some Description",
+ "field turtle_description should be set to the result of the onchange");
+ form.destroy();
+ });
+
+ QUnit.test('one2many list (editable): discarding required empty data', function (assert) {
+ assert.expect(7);
+
+ this.data.turtle.fields.turtle_foo.required = true;
+ delete this.data.turtle.fields.turtle_foo.default;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group>' +
+ '<field name="turtles">' +
+ '<tree editable="top">' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</form>',
+ res_id: 2,
+ mockRPC: function (route, args) {
+ if (args.method) {
+ assert.step(args.method);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // edit mode, then click on Add an item, then click elsewhere
+ assert.strictEqual(form.$('tr.o_data_row').length, 0,
+ "should have 0 data rows");
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('label.o_form_label').first().click();
+ assert.strictEqual(form.$('tr.o_data_row').length, 0,
+ "should still have 0 data rows");
+
+ // click on Add an item again, then click on save
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$buttons.find('.o_form_button_save').click();
+ assert.strictEqual(form.$('tr.o_data_row').length, 0,
+ "should still have 0 data rows");
+
+ assert.verifySteps(['read', 'default_get', 'default_get']);
+ form.destroy();
+ });
+
+ QUnit.test('editable one2many list, adding line when only one page', function (assert) {
+ assert.expect(1);
+
+ this.data.partner.records[0].turtles = [1, 2, 3];
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree editable="bottom" limit="3">' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ // add a record, to reach the page size limit
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ // the record currently being added should not count in the pager
+ assert.ok(!form.$('.o_cp_pager').is(':visible'),
+ "pager should not be visible");
+ form.destroy();
+ });
+
+ QUnit.test('editable one2many list, adding line, then discarding', function (assert) {
+ assert.expect(1);
+
+ this.data.turtle.records.push({ id: 4, turtle_foo: 'stephen hawking' });
+ this.data.partner.records[0].turtles = [1, 2, 3, 4];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree editable="bottom" limit="3">' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ // add a record, then discard
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$buttons.find('.o_form_button_cancel').click();
+
+ // confirm the discard operation
+ $('.modal .modal-footer .btn-primary').click(); // click on confirm
+
+ assert.strictEqual(form.$('.o_cp_pager').text().trim(), '1-3 / 4',
+ "pager should still be visible");
+ form.destroy();
+ });
+
+ QUnit.test('editable one2many list, required field and pager', function (assert) {
+ assert.expect(1);
+
+ this.data.turtle.records.push({ id: 4, turtle_foo: 'stephen hawking' });
+ this.data.turtle.fields.turtle_foo.required = true;
+ this.data.partner.records[0].turtles = [1, 2, 3, 4];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree editable="bottom" limit="3">' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ // add a (empty) record
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ // go on next page. The new record is not valid and should be discarded
+ form.$('.o_pager_next').click();
+ assert.strictEqual(form.$('tr.o_data_row').length, 1,
+ "should have 1 data rows");
+
+ form.destroy();
+ });
+
+ QUnit.test('editable one2many list, required field, pager and confirm discard', function (assert) {
+ assert.expect(3);
+
+ this.data.turtle.records.push({ id: 4, turtle_foo: 'stephen hawking' });
+ this.data.turtle.fields.turtle_foo.required = true;
+ this.data.partner.records[0].turtles = [1, 2, 3, 4];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree editable="bottom" limit="3">' +
+ '<field name="turtle_foo"/>' +
+ '<field name="turtle_int"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ // add a record with a dirty state, but not valid
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('input[name="turtle_int"]').val(4321).trigger('input');
+
+ // go to next page. The new record is not valid, but dirty. we should
+ // see a confirm dialog
+ form.$('.o_pager_next').click();
+
+ assert.strictEqual(form.$('.o_cp_pager').text().trim(), '1-3 / 4',
+ "pager should still display the correct total");
+
+ // click on cancel
+ $('.modal .modal-footer .btn-secondary').click(); // click on cancel
+
+ assert.strictEqual(form.$('.o_cp_pager').text().trim(), '1-3 / 4',
+ "pager should again display the correct total");
+ assert.strictEqual(form.$('.o_field_one2many input.o_field_invalid').length, 1,
+ "there should be an invalid input in the one2many");
+ form.destroy();
+ });
+
+ QUnit.test('editable one2many list, adding, discarding, and pager', function (assert) {
+ assert.expect(2);
+
+ this.data.partner.records[0].turtles = [1];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree editable="bottom" limit="3">' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ // add a 4 records record (to make the pager appear)
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ // go on next page
+ form.$('.o_pager_next').click();
+
+ // discard
+ form.$buttons.find('.o_form_button_cancel').click();
+ $('.modal .modal-footer .btn-primary').click();
+
+ assert.strictEqual(form.$('tr.o_data_row').length, 1,
+ "should have 1 data row");
+ assert.ok(!form.$('.o_cp_pager').is(':visible'),
+ "pager should not be visible");
+
+ form.destroy();
+ });
+
+ QUnit.test('unselecting a line with missing required data', function (assert) {
+ assert.expect(5);
+
+ this.data.turtle.fields.turtle_foo.required = true;
+ delete this.data.turtle.fields.turtle_foo.default;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group>' +
+ '<field name="turtles">' +
+ '<tree editable="top">' +
+ '<field name="turtle_foo"/>' +
+ '<field name="turtle_int"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</form>',
+ res_id: 2,
+ });
+
+ // edit mode, then click on Add an item, then click elsewhere
+ assert.strictEqual(form.$('tr.o_data_row').length, 0,
+ "should have 0 data rows");
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ assert.strictEqual(form.$('tr.o_data_row').length, 1,
+ "should have 1 data rows");
+
+ // adding a value in the non required field, so it is dirty, but with
+ // a missing required field
+ form.$('input[name="turtle_int"]').val('12345').trigger('input');
+
+ // click elsewhere,
+ form.$('label.o_form_label').click();
+ assert.strictEqual($('.modal').length, 1,
+ 'a confirmation model should be opened');
+
+ // click on cancel, the line should still be selected
+ $('.modal .modal-footer button.btn-secondary').click();
+ assert.strictEqual(form.$('tr.o_data_row.o_selected_row').length, 1,
+ "should still have 1 selected data row");
+
+ // click elsewhere, and click on ok (on the confirmation dialog)
+ form.$('label.o_form_label').click();
+ $('.modal .modal-footer button.btn-primary').click();
+ assert.strictEqual(form.$('tr.o_data_row').length, 0,
+ "should have 0 data rows (invalid line has been discarded");
+
+ form.destroy();
+ });
+
+ QUnit.test('pressing enter in a o2m with a required empty field', function (assert) {
+ assert.expect(4);
+
+ this.data.turtle.fields.turtle_foo.required = true;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group>' +
+ '<field name="turtles">' +
+ '<tree editable="top">' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</form>',
+ res_id: 2,
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // edit mode, then click on Add an item, then click elsewhere
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('input[name="turtle_foo"]').trigger($.Event('keydown', {
+ which: $.ui.keyCode.ENTER,
+ keyCode: $.ui.keyCode.ENTER,
+ }));
+ assert.ok(form.$('input[name="turtle_foo"]').hasClass('o_field_invalid'),
+ "input should be marked invalid");
+ assert.verifySteps(['read', 'default_get']);
+ form.destroy();
+ });
+
+ QUnit.test('editing a o2m, with required field and onchange', function (assert) {
+ assert.expect(12);
+
+ this.data.turtle.fields.turtle_foo.required = true;
+ delete this.data.turtle.fields.turtle_foo.default;
+ this.data.turtle.onchanges = {
+ turtle_foo: function (obj) {
+ obj.turtle_int = obj.turtle_foo.length;
+ },
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group>' +
+ '<field name="turtles">' +
+ '<tree editable="top">' +
+ '<field name="turtle_foo"/>' +
+ '<field name="turtle_int"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</form>',
+ res_id: 2,
+ mockRPC: function (route, args) {
+ if (args.method) {
+ assert.step(args.method);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // edit mode, then click on Add an item
+ assert.strictEqual(form.$('tr.o_data_row').length, 0,
+ "should have 0 data rows");
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ // input some text in required turtle_foo field
+ form.$('input[name="turtle_foo"]').val('aubergine').trigger('input');
+ assert.strictEqual(form.$('input[name="turtle_int"]').val(), "9",
+ "onchange should have been triggered");
+
+ // save and check everything is fine
+ form.$buttons.find('.o_form_button_save').click();
+ assert.strictEqual(form.$('.o_data_row td:contains(aubergine)').length, 1,
+ "should have one row with turtle_foo value");
+ assert.strictEqual(form.$('.o_data_row td:contains(9)').length, 1,
+ "should have one row with turtle_int value");
+
+ assert.verifySteps(['read', 'default_get', 'onchange', 'onchange', 'write', 'read', 'read']);
+ form.destroy();
+ });
+
+ QUnit.test('editable o2m, pressing ESC discard current changes', function (assert) {
+ assert.expect(5);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree editable="top">' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 2,
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ assert.strictEqual(form.$('tr.o_data_row').length, 1,
+ "there should be one data row");
+
+ form.$('input[name="turtle_foo"]').trigger({ type: 'keydown', which: $.ui.keyCode.ESCAPE });
+ assert.strictEqual(form.$('tr.o_data_row').length, 0,
+ "data row should have been discarded");
+ assert.verifySteps(['read', 'default_get']);
+ form.destroy();
+ });
+
+ QUnit.test('editable o2m with required field, pressing ESC discard current changes', function (assert) {
+ assert.expect(5);
+
+ this.data.turtle.fields.turtle_foo.required = true;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree editable="top">' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 2,
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ assert.strictEqual(form.$('tr.o_data_row').length, 1,
+ "there should be one data row");
+
+ form.$('input[name="turtle_foo"]').trigger({ type: 'keydown', which: $.ui.keyCode.ESCAPE });
+ assert.strictEqual(form.$('tr.o_data_row').length, 0,
+ "data row should have been discarded");
+ assert.verifySteps(['read', 'default_get']);
+ form.destroy();
+ });
+
+ QUnit.test('pressing escape in editable o2m list in dialog', function (assert) {
+ assert.expect(3);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group>' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</form>',
+ res_id: 1,
+ archs: {
+ "partner,false,form": '<form>' +
+ '<field name="p">' +
+ '<tree editable="bottom">' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ },
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ form.$('.o_field_x2many_list_row_add a').click();
+ $('.modal .o_field_x2many_list_row_add a').click();
+
+ assert.strictEqual($('.modal .o_data_row.o_selected_row').length, 1,
+ "there should be a row in edition in the dialog");
+
+ // trigger keydown ESCAPE in the edited row
+ $('.modal .o_data_cell input').trigger({ type: 'keydown', which: $.ui.keyCode.ESCAPE });
+
+ assert.strictEqual($('.modal').length, 1,
+ "dialog should still be open");
+ assert.strictEqual($('.modal .o_data_row').length, 0,
+ "the row should have been removed");
+
+ form.destroy();
+ });
+
+ QUnit.test('editable o2m with onchange and required field: delete an invalid line', function (assert) {
+ assert.expect(5);
+
+ this.data.partner.onchanges = {
+ turtles: function () { },
+ };
+ this.data.partner.records[0].turtles = [1];
+ this.data.turtle.records[0].product_id = 37;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree editable="top">' +
+ '<field name="product_id"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super.apply(this, arguments);
+ },
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ form.$('.o_data_cell:first').click();
+ form.$('.o_field_widget[name="product_id"] input').val('').trigger('keyup');
+ assert.verifySteps(['read', 'read'], 'no onchange should be done as line is invalid');
+ form.$('.o_list_record_remove').click();
+ assert.verifySteps(['read', 'read', 'onchange'], 'onchange should have been done');
+
+ form.destroy();
+ });
+
+ QUnit.test('onchange in a one2many', function (assert) {
+ assert.expect(1);
+
+ this.data.partner.records.push({
+ id: 3,
+ foo: "relational record 1",
+ });
+ this.data.partner.records[1].p = [3];
+ this.data.partner.onchanges = { p: true };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<tree editable="top">' +
+ '<field name="foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 2,
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ return $.when({
+ value: {
+ p: [
+ [5], // delete all
+ [0, 0, { foo: "from onchange" }], // create new
+ ]
+ }
+ });
+ }
+ return this._super(route, args);
+ },
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_one2many tbody td').first().click();
+ form.$('.o_field_one2many tbody td').first().find('input').val("new value").trigger('input');
+ form.$buttons.find('.o_form_button_save').click();
+
+ assert.strictEqual(form.$('.o_field_one2many tbody td').first().text(), 'from onchange',
+ "display name of first record in o2m list should be 'new value'");
+ form.destroy();
+ });
+
+ QUnit.test('one2many, default_get and onchange (basic)', function (assert) {
+ assert.expect(1);
+
+ this.data.partner.fields.p.default = [
+ [6, 0, []], // replace with zero ids
+ ];
+ this.data.partner.onchanges = { p: true };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ return $.when({
+ value: {
+ p: [
+ [5], // delete all
+ [0, 0, { foo: "from onchange" }], // create new
+ ]
+ }
+ });
+ }
+ return this._super(route, args);
+ },
+ });
+
+ assert.ok(form.$('td:contains(from onchange)').length,
+ "should have 'from onchange' value in one2many");
+ form.destroy();
+ });
+
+ QUnit.test('one2many and default_get (with date)', function (assert) {
+ assert.expect(1);
+
+ this.data.partner.fields.p.default = [
+ [0, false, { date: '2017-10-08' }],
+ ];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="date"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ });
+
+ assert.strictEqual(form.$('.o_data_cell').text(), '10/08/2017',
+ "should correctly display the date");
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many and onchange (with integer)', function (assert) {
+ assert.expect(4);
+
+ this.data.turtle.onchanges = {
+ turtle_int: function (obj) { }
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree editable="bottom">' +
+ '<field name="turtle_int"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super.apply(this, arguments);
+ },
+ });
+ form.$buttons.find('.o_form_button_edit').click();
+
+ form.$('td:contains(9)').click();
+ form.$('td input[name="turtle_int"]').val("3").trigger('input');
+
+ // the 'change' event is triggered on the input when we focus somewhere
+ // else, for example by clicking in the body. However, if we try to
+ // programmatically click in the body, it does not trigger a change
+ // event, so we simply trigger it directly instead.
+ form.$('td input[name="turtle_int"]').trigger('change');
+
+ assert.verifySteps(['read', 'read', 'onchange']);
+ form.destroy();
+ });
+
+ QUnit.test('one2many and onchange (with date)', function (assert) {
+ assert.expect(7);
+
+ this.data.partner.onchanges = {
+ date: function (obj) { }
+ };
+ this.data.partner.records[0].p = [2];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<tree editable="bottom">' +
+ '<field name="date"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super.apply(this, arguments);
+ },
+ });
+ form.$buttons.find('.o_form_button_edit').click();
+
+ form.$('td:contains(01/25/2017)').click();
+ form.$('.o_datepicker_input').click();
+ $('.bootstrap-datetimepicker-widget .picker-switch').first().click(); // Month selection
+ $('.bootstrap-datetimepicker-widget .picker-switch').first().click(); // Year selection
+ $('.bootstrap-datetimepicker-widget .year:contains(2017)').click();
+ $('.bootstrap-datetimepicker-widget .month').eq(1).click(); // February
+ $('.day:contains(22)').click(); // select the 22 February
+
+ form.$buttons.find('.o_form_button_save').click();
+
+ assert.verifySteps(['read', 'read', 'onchange', 'write', 'read', 'read']);
+ form.destroy();
+ });
+
+ QUnit.test('one2many and onchange (with command DELETE_ALL)', function (assert) {
+ assert.expect(5);
+
+ this.data.partner.onchanges = {
+ foo: function (obj) {
+ obj.p = [[5]];
+ },
+ p: function () { }, // dummy onchange on the o2m to execute _isX2ManyValid()
+ };
+ this.data.partner.records[0].p = [2];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="foo"/>' +
+ '<field name="p">' +
+ '<tree editable="bottom">' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ mockRPC: function (method, args) {
+ if (args.method === 'write') {
+ assert.deepEqual(args.args[1].p, [
+ [0, args.args[1].p[0][1], { display_name: 'z' }],
+ [2, 2, false],
+ ], "correct commands should be sent");
+ }
+ return this._super.apply(this, arguments);
+ },
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "o2m should contain one row");
+
+ // empty o2m by triggering the onchange
+ form.$('.o_field_widget[name=foo]').val('trigger onchange').trigger('input');
+
+ assert.strictEqual(form.$('.o_data_row').length, 0,
+ "rows of the o2m should have been deleted");
+
+ // add two new subrecords
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('.o_field_widget[name=display_name]').val('x').trigger('input');
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('.o_field_widget[name=display_name]').val('y').trigger('input');
+
+ assert.strictEqual(form.$('.o_data_row').length, 2,
+ "o2m should contain two rows");
+
+ // empty o2m by triggering the onchange
+ form.$('.o_field_widget[name=foo]').val('trigger onchange again').trigger('input');
+
+ assert.strictEqual(form.$('.o_data_row').length, 0,
+ "rows of the o2m should have been deleted");
+
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('.o_field_widget[name=display_name]').val('z').trigger('input');
+
+ form.$buttons.find('.o_form_button_save').click();
+ form.destroy();
+ });
+
+ QUnit.test('one2many and onchange only write modified field', function (assert) {
+ assert.expect(2);
+
+ this.data.partner.onchanges = {
+ turtles: function (obj) {
+ obj.turtles = [
+ [5], // delete all
+ [1, 3, { // the server returns all fields
+ display_name: "coucou",
+ product_id: [37, "xphone"],
+ turtle_bar: false,
+ turtle_foo: "has changed",
+ turtle_int: 42,
+ turtle_qux: 9.8,
+ partner_ids: [],
+ turtle_ref: 'product,37',
+ }],
+ ];
+ },
+ };
+
+ this.data.partner.records[0].turtles = [3];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="foo"/>' +
+ '<field name="turtles">' +
+ '<tree editable="bottom">' +
+ '<field name="display_name"/>' +
+ '<field name="product_id"/>' +
+ '<field name="turtle_bar"/>' +
+ '<field name="turtle_foo"/>' +
+ '<field name="turtle_int"/>' +
+ '<field name="turtle_qux"/>' +
+ '<field name="turtle_ref"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ mockRPC: function (method, args) {
+ if (args.method === 'write') {
+ assert.deepEqual(args.args[1].turtles, [
+ [1, 3, { display_name: 'coucou', turtle_foo: 'has changed', turtle_int: 42 }],
+ ], "correct commands should be sent (only send changed values)");
+ }
+ return this._super.apply(this, arguments);
+ },
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "o2m should contain one row");
+
+ 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');
+
+ form.$buttons.find('.o_form_button_save').click();
+ form.destroy();
+ });
+
+ QUnit.test('one2many with CREATE onchanges correctly refreshed', function (assert) {
+ assert.expect(5);
+
+ var delta = 0;
+ testUtils.patch(AbstractField, {
+ init: function () {
+ delta++;
+ this._super.apply(this, arguments);
+ },
+ destroy: function () {
+ delta--;
+ this._super.apply(this, arguments);
+ },
+ });
+
+ var deactiveOnchange = true;
+
+ this.data.partner.records[0].turtles = [];
+ this.data.partner.onchanges = {
+ turtles: function (obj) {
+ if (deactiveOnchange) { return; }
+ // the onchange will either:
+ // - create a second line if there is only one line
+ // - edit the second line if there are two lines
+ if (obj.turtles.length === 1) {
+ obj.turtles = [
+ [5], // delete all
+ [0, obj.turtles[0][1], {
+ display_name: "first",
+ turtle_int: obj.turtles[0][2].turtle_int,
+ }],
+ [0, 0, {
+ display_name: "second",
+ turtle_int: -obj.turtles[0][2].turtle_int,
+ }],
+ ];
+ } else if (obj.turtles.length === 2) {
+ obj.turtles = [
+ [5], // delete all
+ [0, obj.turtles[0][1], {
+ display_name: "first",
+ turtle_int: obj.turtles[0][2].turtle_int,
+ }],
+ [0, obj.turtles[1][1], {
+ display_name: "second",
+ turtle_int: -obj.turtles[0][2].turtle_int,
+ }],
+ ];
+ }
+ },
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="foo"/>' +
+ '<field name="turtles">' +
+ '<tree editable="bottom">' +
+ '<field name="display_name" widget="char"/>' +
+ '<field name="turtle_int"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ assert.strictEqual(form.$('.o_data_row').length, 0,
+ "o2m shouldn't contain any row");
+
+ form.$('.o_field_x2many_list_row_add a').click();
+ // trigger the first onchange
+ deactiveOnchange = false;
+ form.$('input[name="turtle_int"]').val('10').trigger('input');
+ // put the list back in non edit mode
+ form.$('input[name="foo"]').click();
+ assert.strictEqual(form.$('.o_data_row').text(), "first10second-10",
+ "should correctly refresh the records");
+
+ // trigger the second onchange
+ form.$('.o_field_x2many_list tbody tr:first td:first').click();
+ form.$('input[name="turtle_int"]').val('20').trigger('input');
+
+ form.$('input[name="foo"]').click();
+ assert.strictEqual(form.$('.o_data_row').text(), "first20second-20",
+ "should correctly refresh the records");
+
+ assert.strictEqual(form.$('.o_field_widget').length, delta,
+ "all (non visible) field widgets should have been destroyed");
+
+ form.$buttons.find('.o_form_button_save').click();
+
+ assert.strictEqual(form.$('.o_data_row').text(), "first20second-20",
+ "should correctly refresh the records after save");
+
+ form.destroy();
+ 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) {
+ assert.expect(2);
+
+ this.data.partner.records[0].p = [1, 2, 4];
+ this.data.partner.onchanges = {
+ p: function () { },
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<tree editable="bottom">' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ assert.strictEqual(form.$('.o_data_cell').text(), 'first recordsecond recordaaa',
+ "records should be display in the correct order");
+
+ form.$('.o_data_row:first .o_data_cell').click();
+ form.$('.o_selected_row .o_field_widget[name=display_name]').val('new').trigger('input');
+ form.$el.click(); // click outside to validate the row
+
+ assert.strictEqual(form.$('.o_data_cell').text(), 'newsecond recordaaa',
+ "records should be display in the correct order");
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many list (editable): readonly domain is evaluated', function (assert) {
+ assert.expect(2);
+
+ this.data.partner.records[0].p = [2, 4];
+ this.data.partner.records[1].product_id = false;
+ this.data.partner.records[2].product_id = 37;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<tree editable="top">' +
+ '<field name="display_name" attrs=\'{"readonly": [["product_id", "=", false]]}\'/>' +
+ '<field name="product_id"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+
+ assert.ok(form.$('.o_list_view tbody tr:eq(0) td:first').hasClass('o_readonly_modifier'),
+ "first record should have display_name in readonly mode");
+
+ assert.notOk(form.$('.o_list_view tbody tr:eq(1) td:first').hasClass('o_readonly_modifier'),
+ "second record should not have display_name in readonly mode");
+ form.destroy();
+ });
+
+ QUnit.test('pager of one2many field in new record', function (assert) {
+ assert.expect(2);
+
+ this.data.partner.records[0].p = [];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<tree editable="top">' +
+ '<field name="foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ archs: {
+ 'partner,false,form':
+ '<form string="Partner"><field name="foo"/></form>',
+ },
+ });
+
+ assert.ok(!form.$('.o_x2m_control_panel .o_cp_pager div').is(':visible'),
+ 'o2m pager should be hidden');
+
+ // click to create a subrecord
+ form.$('tbody td.o_field_x2many_list_row_add a').click();
+ $('.modal input').val('new record').trigger('input');
+ $('.modal .modal-footer button:eq(0)').click(); // save and close
+
+ assert.ok(!form.$('.o_x2m_control_panel .o_cp_pager div').is(':visible'),
+ 'o2m pager should be hidden');
+ form.destroy();
+ });
+
+ QUnit.test('one2many list with a many2one', function (assert) {
+ assert.expect(5);
+
+ this.data.partner.records[0].p = [2];
+ this.data.partner.records[1].product_id = 37;
+ this.data.partner.onchanges.p = function (obj) {
+ obj.p = [
+ [5], // delete all
+ [1, 2, { product_id: [37, "xphone"] }], // update existing record
+ [0, 0, { product_id: [41, "xpad"] }]
+ ];
+ //
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="product_id"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ archs: {
+ 'partner,false,form':
+ '<form string="Partner"><field name="product_id"/></form>',
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ assert.deepEqual(args.args[1].p, [[4, 2, false], [0, args.args[1].p[1][1], { product_id: 41 }]],
+ "should trigger onchange with correct parameters");
+ }
+ return this._super.apply(this, arguments);
+ }
+ });
+
+ assert.strictEqual(form.$('tbody td:contains(xphone)').length, 1,
+ "should have properly fetched the many2one nameget");
+ assert.strictEqual(form.$('tbody td:contains(xpad)').length, 0,
+ "should not display 'xpad' anywhere");
+
+ form.$buttons.find('.o_form_button_edit').click();
+
+ form.$('tbody td.o_field_x2many_list_row_add a').click();
+
+ $('.modal .o_field_many2one input').click();
+
+ var $dropdown = $('.modal .o_field_many2one input').autocomplete('widget');
+
+ $dropdown.find('li:eq(1) a').mouseenter();
+ $dropdown.find('li:eq(1) a').click();
+
+ $('.modal .modal-footer button:eq(0)').click(); // save and close
+
+ assert.strictEqual(form.$('tbody td:contains(xpad)').length, 1,
+ "should display 'xpad' on a td");
+ assert.strictEqual(form.$('tbody td:contains(xphone)').length, 1,
+ "should still display xphone");
+ form.destroy();
+ });
+
+ QUnit.test('one2many list with inline form view', function (assert) {
+ assert.expect(5);
+
+ this.data.partner.records[0].p = [];
+
+ var rpcCount = 0;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<form string="Partner">' +
+ '<field name="product_id"/>' +
+ '<field name="int_field"/>' +
+ '</form>' +
+ '<tree>' +
+ '<field name="product_id"/>' +
+ '<field name="foo"/>' + // don't remove this, it is
+ // useful to make sure the foo fieldwidget
+ // does not crash because the foo field
+ // is not in the form view
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ rpcCount++;
+ if (args.method === 'write') {
+ assert.deepEqual(args.args[1].p, [[0, args.args[1].p[0][1], {
+ foo: false, int_field: 123, product_id: 41,
+ }]]);
+ }
+ return this._super(route, args);
+ },
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+
+ form.$('tbody td.o_field_x2many_list_row_add a').click();
+
+ // write in the many2one field, value = 37 (xphone)
+ $('.modal .o_field_many2one input').click();
+ var $dropdown = $('.modal .o_field_many2one input').autocomplete('widget');
+ $dropdown.find('li:eq(0) a').mouseenter();
+ $dropdown.find('li:eq(0) a').click();
+
+ // write in the integer field
+ $('.modal .modal-body input.o_field_widget').val('123').trigger('input');
+
+ // save and close
+ $('.modal .modal-footer button:eq(0)').click();
+
+ assert.strictEqual(form.$('tbody td:contains(xphone)').length, 1,
+ "should display 'xphone' in a td");
+
+ // reopen the record in form view
+ form.$('tbody td:contains(xphone)').click();
+
+ assert.strictEqual($('.modal .modal-body input').val(), "xphone",
+ "should display 'xphone' in an input");
+
+ $('.modal .modal-body input.o_field_widget').val('456').trigger('input');
+
+ // discard
+ $('.modal .modal-footer span:contains(Discard)').click();
+
+ // reopen the record in form view
+ form.$('tbody td:contains(xphone)').click();
+
+ assert.strictEqual($('.modal .modal-body input.o_field_widget').val(), "123",
+ "should display 123 (previous change has been discarded)");
+
+ // write in the many2one field, value = 41 (xpad)
+ $('.modal .o_field_many2one input').click();
+ $dropdown = $('.modal .o_field_many2one input').autocomplete('widget');
+ $dropdown.find('li:eq(1) a').mouseenter();
+ $dropdown.find('li:eq(1) a').click();
+
+ // save and close
+ $('.modal .modal-footer button:eq(0)').click();
+
+ assert.strictEqual(form.$('tbody td:contains(xpad)').length, 1,
+ "should display 'xpad' in a td");
+
+ // save the record
+ form.$buttons.find('.o_form_button_save').click();
+ form.destroy();
+ });
+
+ QUnit.test('one2many list with inline form view with context with parent key', function (assert) {
+ assert.expect(2);
+
+ this.data.partner.records[0].p = [2];
+ this.data.partner.records[0].product_id = 41;
+ this.data.partner.records[1].product_id = 37;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="foo"/>' +
+ '<field name="product_id"/>' +
+ '<field name="p">' +
+ '<form string="Partner">' +
+ '<field name="product_id" context="{\'partner_foo\':parent.foo, \'lalala\': parent.product_id}"/>' +
+ '</form>' +
+ '<tree>' +
+ '<field name="product_id"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.method === 'name_search') {
+ assert.strictEqual(args.kwargs.context.partner_foo, "yop",
+ "should have correctly evaluated parent foo field");
+ assert.strictEqual(args.kwargs.context.lalala, 41,
+ "should have correctly evaluated parent product_id field");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ // open a modal
+ form.$('tr.o_data_row:eq(0) td:contains(xphone)').click();
+
+ // write in the many2one field
+ $('.modal .o_field_many2one input').click();
+
+ form.destroy();
+ });
+
+ QUnit.test('value of invisible x2many fields is correctly evaluated in context', function (assert) {
+ assert.expect(1);
+
+ this.data.partner.records[0].timmy = [12];
+ this.data.partner.records[0].p = [2, 3];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:
+ '<form string="Partners">' +
+ '<field name="product_id" context="{\'p\': p, \'timmy\': timmy}"/>' +
+ '<field name="p" invisible="1"/>' +
+ '<field name="timmy" invisible="1"/>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.method === 'name_search') {
+ assert.deepEqual(
+ args.kwargs.context, {
+ p: [[4, 2, false], [4, 3, false]],
+ timmy: [[6, false, [12]]],
+ }, 'values of x2manys should have been correctly evaluated in context');
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_widget[name=product_id] input').click();
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many list, editable, with many2one and with context with parent key', function (assert) {
+ assert.expect(1);
+
+ this.data.partner.records[0].p = [2];
+ this.data.partner.records[1].product_id = 37;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="foo"/>' +
+ '<field name="p">' +
+ '<tree editable="bottom">' +
+ '<field name="product_id" context="{\'partner_foo\':parent.foo}"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.method === 'name_search') {
+ assert.strictEqual(args.kwargs.context.partner_foo, "yop",
+ "should have correctly evaluated parent foo field");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+
+ form.$('tr.o_data_row:eq(0) td:contains(xphone)').click();
+
+ // trigger a name search
+ form.$('table td input').click();
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many list, editable, with a date in the context', function (assert) {
+ assert.expect(1);
+
+ this.data.partner.records[0].p = [2];
+ this.data.partner.records[1].product_id = 37;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group>' +
+ '<field name="date"/>' +
+ '<field name="p" context="{\'date\':date}">' +
+ '<tree editable="top">' +
+ '<field name="date"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</form>',
+ res_id: 2,
+ mockRPC: function (route, args) {
+ if (args.method === 'default_get') {
+ assert.strictEqual(args.kwargs.context.date, '2017-01-25',
+ "should have properly evaluated date key in context");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many field with context', function (assert) {
+ assert.expect(2);
+
+ var counter = 0;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group>' +
+ '<field name="turtles" context="{\'turtles\':turtles}">' +
+ '<tree editable="bottom">' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.method === 'default_get') {
+ var expected = counter === 0 ?
+ [[4, 2, false]] :
+ [[4, 2, false], [0, args.kwargs.context.turtles[1][1], { turtle_foo: 'hammer' }]];
+ assert.deepEqual(args.kwargs.context.turtles, expected,
+ "should have properly evaluated turtles key in context");
+ counter++;
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('input[name="turtle_foo"]').val('hammer').trigger('input');
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.destroy();
+ });
+
+ QUnit.test('one2many list edition, some basic functionality', function (assert) {
+ assert.expect(3);
+
+ this.data.partner.fields.foo.default = false;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<tree editable="top">' +
+ '<field name="foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+ form.$buttons.find('.o_form_button_edit').click();
+
+ form.$('tbody td.o_field_x2many_list_row_add a').click();
+
+ assert.strictEqual(form.$('td input.o_field_widget').length, 1,
+ "should have created a row in edit mode");
+
+ form.$('td input.o_field_widget').val('a').trigger('input');
+
+ assert.strictEqual(form.$('td input.o_field_widget').length, 1,
+ "should not have unselected the row after edition");
+
+ form.$('td input.o_field_widget').val('abc').trigger('input');
+ form.$buttons.find('.o_form_button_save').click();
+
+ assert.strictEqual(form.$('td:contains(abc)').length, 1,
+ "should have a row with the correct value");
+ form.destroy();
+ });
+
+ QUnit.test('one2many list, the context is properly evaluated and sent', function (assert) {
+ assert.expect(2);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="int_field"/>' +
+ '<field name="p" context="{\'hello\': \'world\', \'abc\': int_field}">' +
+ '<tree editable="top">' +
+ '<field name="foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.method === 'default_get') {
+ var context = args.kwargs.context;
+ assert.strictEqual(context.hello, "world");
+ assert.strictEqual(context.abc, 10);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('tbody td.o_field_x2many_list_row_add a').click();
+ form.destroy();
+ });
+
+ QUnit.test('one2many with many2many widget: create', function (assert) {
+ assert.expect(10);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles" widget="many2many">' +
+ '<tree>' +
+ '<field name="turtle_foo"/>' +
+ '<field name="turtle_qux"/>' +
+ '<field name="turtle_int"/>' +
+ '<field name="product_id"/>' +
+ '</tree>' +
+ '<form>' +
+ '<group>' +
+ '<field name="turtle_foo"/>' +
+ '<field name="turtle_bar"/>' +
+ '<field name="turtle_int"/>' +
+ '<field name="product_id"/>' +
+ '</group>' +
+ '</form>' +
+ '</field>' +
+ '</form>',
+ archs: {
+ 'turtle,false,list': '<tree><field name="display_name"/><field name="turtle_foo"/><field name="turtle_bar"/><field name="product_id"/></tree>',
+ 'turtle,false,search': '<search><field name="turtle_foo"/><field name="turtle_bar"/><field name="product_id"/></search>',
+ },
+ session: {},
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/call_kw/turtle/create') {
+ assert.ok(args.args, "should write on the turtle record");
+ }
+ if (route === '/web/dataset/call_kw/partner/write') {
+ assert.strictEqual(args.args[0][0], 1, "should write on the partner record 1");
+ assert.strictEqual(args.args[1].turtles[0][0], 6, "should send only a 'replace with' command");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ assert.strictEqual($('.modal .o_data_row').length, 2,
+ "sould have 2 records in the select view (the last one is not displayed because it is already selected)");
+
+ $('.modal .o_data_row:first .o_list_record_selector input').click();
+ $('.modal .o_select_button').click();
+ $('.o_form_button_save').click();
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ assert.strictEqual($('.modal .o_data_row').length, 1,
+ "sould have 1 record in the select view");
+
+ $('.modal-footer button:eq(1)').click();
+ $('.modal input.o_field_widget[name="turtle_foo"]').val('tototo').trigger('input');
+ $('.modal input.o_field_widget[name="turtle_int"]').val(50).trigger('input');
+ var $many2one = $('.modal [name="product_id"] input').click();
+ var $dropdown = $many2one.autocomplete('widget');
+ $dropdown.find('li:first a').mouseenter();
+ $dropdown.find('li:first a').click();
+
+ $('.modal-footer button:contains(&):first').click();
+
+ assert.strictEqual($('.modal').length, 0, "sould close the modals");
+
+ assert.strictEqual(form.$('.o_data_row').length, 3,
+ "sould have 3 records in one2many list");
+ assert.strictEqual(form.$('.o_data_row').text(), "blip1.59yop1.50tototo1.550xphone",
+ "should display the record values in one2many list");
+
+ $('.o_form_button_save').click();
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many with many2many widget: edition', function (assert) {
+ assert.expect(7);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles" widget="many2many">' +
+ '<tree>' +
+ '<field name="turtle_foo"/>' +
+ '<field name="turtle_qux"/>' +
+ '<field name="turtle_int"/>' +
+ '<field name="product_id"/>' +
+ '</tree>' +
+ '<form>' +
+ '<group>' +
+ '<field name="turtle_foo"/>' +
+ '<field name="turtle_bar"/>' +
+ '<field name="turtle_int"/>' +
+ '<field name="turtle_trululu"/>' +
+ '<field name="product_id"/>' +
+ '</group>' +
+ '</form>' +
+ '</field>' +
+ '</form>',
+ archs: {
+ 'turtle,false,list': '<tree><field name="display_name"/><field name="turtle_foo"/><field name="turtle_bar"/><field name="product_id"/></tree>',
+ 'turtle,false,search': '<search><field name="turtle_foo"/><field name="turtle_bar"/><field name="product_id"/></search>',
+ },
+ session: {},
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/call_kw/turtle/write') {
+ assert.strictEqual(args.args[0].length, 1, "should write on the turtle record");
+ assert.deepEqual(args.args[1], { "product_id": 37 }, "should write only the product_id on the turtle record");
+ }
+ if (route === '/web/dataset/call_kw/partner/write') {
+ assert.strictEqual(args.args[0][0], 1, "should write on the partner record 1");
+ assert.strictEqual(args.args[1].turtles[0][0], 6, "should send only a 'replace with' command");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ form.$('.o_data_row:first').click();
+ assert.strictEqual($('.modal .modal-title').first().text().trim(), 'Open: one2many turtle field',
+ "modal should use the python field string as title");
+ $('.modal .o_form_button_cancel').click();
+ form.$buttons.find('.o_form_button_edit').click();
+
+ // edit the first one2many record
+ form.$('.o_data_row:first').click();
+ var $many2one = $('.modal [name="product_id"] input').click();
+ var $dropdown = $many2one.autocomplete('widget');
+ $dropdown.find('li:first a').mouseenter();
+ $dropdown.find('li:first a').click();
+ $('.modal-footer button:first').click();
+
+ $('.o_form_button_save').click(); // don't save anything because the one2many does not change
+
+ // add a one2many record
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ $('.modal .o_data_row:first .o_list_record_selector input').click();
+ $('.modal .o_select_button').click();
+
+ // edit the second one2many record
+ form.$('.o_data_row:eq(1)').click();
+ $many2one = $('.modal [name="product_id"] input').click();
+ $dropdown = $many2one.autocomplete('widget');
+ $dropdown.find('li:first a').mouseenter();
+ $dropdown.find('li:first a').click();
+ $('.modal-footer button:first').click();
+
+ $('.o_form_button_save').click();
+
+ form.destroy();
+ });
+
+ QUnit.test('new record, the context is properly evaluated and sent', function (assert) {
+ assert.expect(2);
+
+ this.data.partner.fields.int_field.default = 17;
+ var n = 0;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="int_field"/>' +
+ '<field name="p" context="{\'hello\': \'world\', \'abc\': int_field}">' +
+ '<tree editable="top">' +
+ '<field name="foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'default_get') {
+ n++;
+ if (n === 2) {
+ var context = args.kwargs.context;
+ assert.strictEqual(context.hello, "world");
+ assert.strictEqual(context.abc, 17);
+ }
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('tbody td.o_field_x2many_list_row_add a').click();
+ form.destroy();
+ });
+
+ QUnit.test('parent data is properly sent on an onchange rpc', function (assert) {
+ assert.expect(1);
+
+ this.data.partner.onchanges = { bar: function () { } };
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="foo"/>' +
+ '<field name="p">' +
+ '<tree editable="top">' +
+ '<field name="bar"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ var fieldValues = args.args[1];
+ assert.strictEqual(fieldValues.trululu.foo, "yop",
+ "should have properly sent the parent foo value");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('tbody td.o_field_x2many_list_row_add a').click();
+ form.destroy();
+ });
+
+ QUnit.test('parent data is properly sent on an onchange rpc, new record', function (assert) {
+ assert.expect(6);
+
+ this.data.turtle.onchanges = { turtle_bar: function () { } };
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="foo"/>' +
+ '<field name="turtles">' +
+ '<tree editable="top">' +
+ '<field name="turtle_bar"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ if (args.method === 'onchange' && args.model === 'turtle') {
+ var fieldValues = args.args[1];
+ assert.strictEqual(fieldValues.turtle_trululu.foo, "My little Foo Value",
+ "should have properly sent the parent foo value");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('tbody td.o_field_x2many_list_row_add a').click();
+ assert.verifySteps(['default_get', 'onchange', 'default_get', 'onchange']);
+ form.destroy();
+ });
+
+ QUnit.test('id in one2many obtained in onchange is properly set', function (assert) {
+ assert.expect(1);
+
+ this.data.partner.onchanges.turtles = function (obj) {
+ obj.turtles = [
+ [5],
+ [1, 3, { turtle_foo: "kawa" }]
+ ];
+ };
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree>' +
+ '<field name="id"/>' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ });
+
+ assert.strictEqual(form.$('tr.o_data_row').text(), '3kawa',
+ "should have properly displayed id and foo field");
+ form.destroy();
+ });
+
+ QUnit.test('id field in one2many in a new record', function (assert) {
+ assert.expect(1);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree editable="bottom">' +
+ '<field name="id" invisible="1"/>' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'create') {
+ var virtualID = args.args[0].turtles[0][1];
+ assert.deepEqual(args.args[0].turtles,
+ [[0, virtualID, { turtle_foo: "cat" }]],
+ 'should send proper commands');
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ form.$('td.o_field_x2many_list_row_add a').click();
+ form.$('td input[name="turtle_foo"]').val('cat').trigger('input');
+ form.$buttons.find('.o_form_button_save').click();
+
+ form.destroy();
+ });
+
+ QUnit.test('sub form view with a required field', function (assert) {
+ assert.expect(2);
+ this.data.partner.fields.foo.required = true;
+ this.data.partner.fields.foo.default = null;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<form string="Partner">' +
+ '<group><field name="foo"/></group>' +
+ '</form>' +
+ '<tree>' +
+ '<field name="foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('tbody td.o_field_x2many_list_row_add a').click();
+ $('.modal-footer button.btn-primary').first().click();
+
+ assert.strictEqual($('.modal').length, 1, "should still have an open modal");
+ assert.strictEqual($('.modal tbody label.o_field_invalid').length, 1,
+ "should have displayed invalid fields");
+ form.destroy();
+ });
+
+ QUnit.test('one2many list with action button', function (assert) {
+ assert.expect(4);
+
+ this.data.partner.records[0].p = [2];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="int_field"/>' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="foo"/>' +
+ '<button name="method_name" type="object" icon="fa-plus"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ intercepts: {
+ execute_action: function (event) {
+ assert.deepEqual(event.data.env.currentID, 2,
+ 'should call with correct id');
+ assert.strictEqual(event.data.env.model, 'partner',
+ 'should call with correct model');
+ assert.strictEqual(event.data.action_data.name, 'method_name',
+ "should call correct method");
+ assert.strictEqual(event.data.action_data.type, 'object',
+ 'should have correct type');
+ },
+ },
+ });
+
+ form.$('.o_list_button button').click();
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many kanban with action button', function (assert) {
+ assert.expect(4);
+
+ this.data.partner.records[0].p = [2];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<kanban>' +
+ '<field name="foo"/>' +
+ '<templates>' +
+ '<t t-name="kanban-box">' +
+ '<div>' +
+ '<span><t t-esc="record.foo.value"/></span>' +
+ '<button name="method_name" type="object" class="fa fa-plus"/>' +
+ '</div>' +
+ '</t>' +
+ '</templates>' +
+ '</kanban>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ intercepts: {
+ execute_action: function (event) {
+ assert.deepEqual(event.data.env.currentID, 2,
+ 'should call with correct id');
+ assert.strictEqual(event.data.env.model, 'partner',
+ 'should call with correct model');
+ assert.strictEqual(event.data.action_data.name, 'method_name',
+ "should call correct method");
+ assert.strictEqual(event.data.action_data.type, 'object',
+ 'should have correct type');
+ },
+ },
+ });
+
+ form.$('.oe_kanban_action_button').click();
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many kanban with edit type action and domain widget (widget using SpecialData)', function (assert) {
+ assert.expect(1);
+
+ this.data.turtle.fields.model_name = { string: "Domain Condition Model", type: "char" };
+ this.data.turtle.fields.condition = { string: "Domain Condition", type: "char" };
+ _.each(this.data.turtle.records, function (record) {
+ record.model_name = 'partner';
+ record.condition = '[]';
+ });
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group>' +
+ '<field name="turtles" mode="kanban">' +
+ '<kanban>' +
+ '<templates>' +
+ '<t t-name="kanban-box">' +
+ '<div><field name="display_name"/></div>' +
+ '<div><field name="turtle_foo"/></div>' +
+ // field without Widget in the list
+ '<div><field name="condition"/></div>' +
+ '<div> <a type="edit"> Edit </a> </div>' +
+ '</t>' +
+ '</templates>' +
+ '</kanban>' +
+ '<form>' +
+ '<field name="product_id" widget="statusbar"/>' +
+ '<field name="model_name"/>' +
+ // field with Widget requiring specialData in the form
+ '<field name="condition" widget="domain" options="{\'model\': \'model_name\'}"/>' +
+ '</form>' +
+ '</field>' +
+ '</group>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ form.$('.oe_kanban_action:eq(0)').click();
+ assert.strictEqual($('.o_domain_selector').length, 1, "should add domain selector widget");
+ form.destroy();
+ });
+
+ QUnit.test('one2many list with onchange and domain widget (widget using SpecialData)', function (assert) {
+ assert.expect(3);
+
+ this.data.turtle.fields.model_name = { string: "Domain Condition Model", type: "char" };
+ this.data.turtle.fields.condition = { string: "Domain Condition", type: "char" };
+ _.each(this.data.turtle.records, function (record) {
+ record.model_name = 'partner';
+ record.condition = '[]';
+ });
+ this.data.partner.onchanges = {
+ turtles: function (obj) {
+ var virtualID = obj.turtles[1][1];
+ obj.turtles = [
+ [5], // delete all
+ [0, virtualID, {
+ display_name: "coucou",
+ product_id: [37, "xphone"],
+ turtle_bar: false,
+ turtle_foo: "has changed",
+ turtle_int: 42,
+ turtle_qux: 9.8,
+ partner_ids: [],
+ turtle_ref: 'product,37',
+ model_name: 'partner',
+ condition: '[]',
+ }],
+ ];
+ },
+ };
+ var nbFetchSpecialDomain = 0;
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group>' +
+ '<field name="turtles" mode="tree">' +
+ '<tree>' +
+ '<field name="display_name"/>' +
+ '<field name="turtle_foo"/>' +
+ // field without Widget in the list
+ '<field name="condition"/>' +
+ '</tree>' +
+ '<form>' +
+ '<field name="model_name"/>' +
+ // field with Widget requiring specialData in the form
+ '<field name="condition" widget="domain" options="{\'model\': \'model_name\'}"/>' +
+ '</form>' +
+ '</field>' +
+ '</group>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ mockRPC: function (route) {
+ if (route === '/web/dataset/call_kw/partner/search_count') {
+ nbFetchSpecialDomain++;
+ }
+ return this._super.apply(this, arguments);
+ }
+ });
+
+ form.$('.o_field_one2many .o_field_x2many_list_row_add a').click();
+ assert.strictEqual($('.modal').length, 1, "form view dialog should be opened");
+ $('.modal-body input[name="model_name"]').val('partner').trigger('input');
+ $('.modal-footer button:first').click();
+
+ assert.strictEqual(form.$('.o_field_one2many tbody tr:first').text(), "coucouhas changed[]",
+ "the onchange should create one new record and remove the existing");
+
+ form.$('.o_field_one2many .o_list_view tbody tr:eq(0) td:first').click();
+
+ form.$buttons.find('.o_form_button_save').click();
+ assert.strictEqual(nbFetchSpecialDomain, 1,
+ "should only fetch special domain once");
+ form.destroy();
+ });
+
+ QUnit.test('one2many without inline tree arch', function (assert) {
+ assert.expect(2);
+
+ this.data.partner.records[0].turtles = [2, 3];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group>' +
+ '<field name="p" widget="many2many_tags"/>' + // check if the view don not call load view (widget without useSubview)
+ '<field name="turtles"/>' +
+ '<field name="timmy" invisible="1"/>' + // check if the view don not call load view in invisible
+ '</group>' +
+ '</form>',
+ res_id: 1,
+ archs: {
+ "turtle,false,list": '<tree string="Turtles"><field name="turtle_bar"/><field name="display_name"/><field name="partner_ids"/></tree>',
+ }
+ });
+
+ assert.strictEqual(form.$('.o_field_widget[name="turtles"] .o_list_view').length, 1,
+ 'should display one2many list view in the modal');
+
+ assert.strictEqual(form.$('.o_data_row').length, 2,
+ 'should display the 2 turtles');
+
+ form.destroy();
+ });
+
+ QUnit.test('many2one and many2many in one2many', function (assert) {
+ assert.expect(11);
+
+ this.data.turtle.records[1].product_id = 37;
+ this.data.partner.records[0].turtles = [2, 3];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group>' +
+ '<field name="int_field"/>' +
+ '<field name="turtles">' +
+ '<form string="Turtles">' +
+ '<group>' +
+ '<field name="product_id"/>' +
+ '</group>' +
+ '</form>' +
+ '<tree editable="top">' +
+ '<field name="display_name"/>' +
+ '<field name="product_id"/>' +
+ '<field name="partner_ids" widget="many2many_tags"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ var commands = args.args[1].turtles;
+ assert.strictEqual(commands.length, 2,
+ "should have generated 2 commands");
+ assert.deepEqual(commands[0], [1, 2, {
+ partner_ids: [[6, false, [2, 1]]],
+ product_id: 41,
+ }], "generated commands should be correct");
+ assert.deepEqual(commands[1], [4, 3, false],
+ "generated commands should be correct");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual(form.$('.o_data_row').length, 2,
+ 'should display the 2 turtles');
+ assert.strictEqual(form.$('.o_data_row:first td:nth(1)').text(), 'xphone',
+ "should correctly display the m2o");
+ assert.strictEqual(form.$('.o_data_row:first td:nth(2) .badge').length, 2,
+ "m2m should contain two tags");
+ assert.strictEqual(form.$('.o_data_row:first td:nth(2) .badge:first span').text(),
+ 'second record', "m2m values should have been correctly fetched");
+
+ form.$('.o_data_row:first').click();
+
+ assert.strictEqual($('.modal .o_field_widget').text(), "xphone",
+ 'should display the form view dialog with the many2one value');
+ $('.modal-footer button').click(); // close the modal
+
+ form.$buttons.find('.o_form_button_edit').click();
+
+ // edit the m2m of first row
+ form.$('.o_list_view tbody td:first()').click();
+ // remove a tag
+ form.$('.o_field_many2manytags .badge:contains(aaa) .o_delete').click();
+ assert.strictEqual(form.$('.o_selected_row .o_field_many2manytags .o_badge_text:contains(aaa)').length, 0,
+ "tag should have been correctly removed");
+ // add a tag
+ var $m2mInput = form.$('.o_selected_row .o_field_many2manytags input');
+ $m2mInput.click();
+ $m2mInput.autocomplete('widget').find('li:first()').click();
+ assert.strictEqual(form.$('.o_selected_row .o_field_many2manytags .o_badge_text:contains(first record)').length, 1,
+ "tag should have been correctly added");
+
+ // edit the m2o of first row
+ var $m2oInput = form.$('.o_selected_row .o_field_many2one:first input');
+ $m2oInput.click();
+ $m2oInput.autocomplete('widget').find('li:contains(xpad)').mouseover().click();
+ assert.strictEqual(form.$('.o_selected_row .o_field_many2one:first input').val(), 'xpad',
+ "m2o value should have been updated");
+
+ // save (should correctly generate the commands)
+ form.$buttons.find('.o_form_button_save').click();
+
+ form.destroy();
+ });
+
+ QUnit.test('many2manytag in one2many, onchange, some modifiers, and more than one page', function (assert) {
+ assert.expect(9);
+
+ this.data.partner.records[0].turtles = [1, 2, 3];
+
+ this.data.partner.onchanges.turtles = function () { };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree editable="top" limit="2">' +
+ '<field name="turtle_foo"/>' +
+ '<field name="partner_ids" widget="many2many_tags" attrs="{\'readonly\': [(\'turtle_foo\', \'=\', \'a\')]}"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: { mode: 'edit' },
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super.apply(this, arguments);
+ },
+ });
+ assert.strictEqual(form.$('.o_data_row').length, 2,
+ 'there should be only 2 rows displayed');
+ form.$('.o_list_record_remove').click();
+ form.$('.o_list_record_remove').click();
+
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ 'there should be just one remaining row');
+
+ assert.verifySteps([
+ "read", // initial read on partner
+ "read", // initial read on turtle
+ "read", // batched read on partner (field partner_ids)
+ "read", // after first delete, read on turtle (to fetch 3rd record)
+ "onchange", // after first delete, onchange on field turtles
+ "onchange" // onchange after second delete
+ ]);
+
+ form.destroy();
+ });
+
+ QUnit.test('onchange many2many in one2many list editable', function (assert) {
+ assert.expect(14);
+
+ this.data.product.records.push({
+ id: 1,
+ display_name: "xenomorphe",
+ });
+
+ this.data.turtle.onchanges = {
+ product_id: function (rec) {
+ if (rec.product_id) {
+ rec.partner_ids = [
+ [5],
+ [4, rec.product_id === 41 ? 1 : 2]
+ ];
+ }
+ },
+ };
+ var partnerOnchange = function (rec) {
+ if (!rec.int_field || !rec.turtles.length) {
+ return;
+ }
+ rec.turtles = [
+ [5],
+ [0, 0, {
+ display_name: 'new line',
+ product_id: [37, 'xphone'],
+ partner_ids: [
+ [5],
+ [4, 1]
+ ]
+ }],
+ [0, rec.turtles[0][1], {
+ display_name: rec.turtles[0][2].display_name,
+ product_id: [1, 'xenomorphe'],
+ partner_ids: [
+ [5],
+ [4, 2]
+ ]
+ }],
+ ];
+ };
+
+ this.data.partner.onchanges = {
+ int_field: partnerOnchange,
+ turtles: partnerOnchange,
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group>' +
+ '<field name="int_field"/>' +
+ '<field name="turtles">' +
+ '<tree editable="bottom">' +
+ '<field name="display_name"/>' +
+ '<field name="product_id"/>' +
+ '<field name="partner_ids" widget="many2many_tags"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</form>',
+ });
+
+ // add new line (first, xpad)
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('input[name="display_name"]').val('first').trigger('input');
+ form.$('div[name="product_id"] input').click();
+ // the onchange won't be generated
+ $('li.ui-menu-item a:contains(xpad)').trigger('mouseenter').click();
+
+ assert.strictEqual(form.$('.o_field_many2manytags.o_input').length, 1,
+ 'should display the line in editable mode');
+ assert.strictEqual(form.$('.o_field_many2one input').val(), "xpad",
+ 'should display the product xpad');
+ assert.strictEqual(form.$('.o_field_many2manytags.o_input .o_badge_text').text(), "first record",
+ 'should display the tag from the onchange');
+
+ form.$('input.o_field_integer[name="int_field"]').click();
+
+ assert.strictEqual(form.$('.o_data_cell.o_required_modifier').text(), "xpad",
+ 'should display the product xpad');
+ assert.strictEqual(form.$('.o_field_many2manytags:not(.o_input) .o_badge_text').text(), "first record",
+ 'should display the tag in readonly');
+
+ // enable the many2many onchange and generate it
+ form.$('input.o_field_integer[name="int_field"]').val('10').trigger('input');
+
+ assert.strictEqual(form.$('.o_data_cell.o_required_modifier').text(), "xenomorphexphone",
+ 'should display the product xphone and xenomorphe');
+ assert.strictEqual(form.$('.o_data_row').text().replace(/\s+/g, ' '), "firstxenomorphe second record new linexphone first record ",
+ 'should display the name, one2many and many2many value');
+
+ // disable the many2many onchange
+ form.$('input.o_field_integer[name="int_field"]').val('0').trigger('input');
+
+ // remove and start over
+ form.$('.o_list_record_remove:first button').click();
+ form.$('.o_list_record_remove:first button').click();
+
+ // enable the many2many onchange
+ form.$('input.o_field_integer[name="int_field"]').val('10').trigger('input');
+
+ // add new line (first, xenomorphe)
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('input[name="display_name"]').val('first').trigger('input');
+ form.$('div[name="product_id"] input').click();
+ // generate the onchange
+ $('li.ui-menu-item a:contains(xenomorphe)').trigger('mouseenter').click();
+
+ assert.strictEqual(form.$('.o_field_many2manytags.o_input').length, 1,
+ 'should display the line in editable mode');
+ assert.strictEqual(form.$('.o_field_many2one input').val(), "xenomorphe",
+ 'should display the product xenomorphe');
+ assert.strictEqual(form.$('.o_field_many2manytags.o_input .o_badge_text').text(), "second record",
+ 'should display the tag from the onchange');
+
+ // put list in readonly mode
+ form.$('input.o_field_integer[name="int_field"]').click();
+
+ assert.strictEqual(form.$('.o_data_cell.o_required_modifier').text(), "xenomorphexphone",
+ 'should display the product xphone and xenomorphe');
+ assert.strictEqual(form.$('.o_field_many2manytags:not(.o_input) .o_badge_text').text(), "second recordfirst record",
+ 'should display the tag in readonly (first record and second record)');
+
+ form.$('input.o_field_integer[name="int_field"]').val('10').trigger('input');
+
+ assert.strictEqual(form.$('.o_data_row').text().replace(/\s+/g, ' '), "firstxenomorphe second record new linexphone first record ",
+ 'should display the name, one2many and many2many value');
+
+ form.$buttons.find('.o_form_button_save').click();
+
+ assert.strictEqual(form.$('.o_data_row').text().replace(/\s+/g, ' '), "firstxenomorphe second record new linexphone first record ",
+ 'should display the name, one2many and many2many value after save');
+
+ form.destroy();
+ });
+
+ QUnit.test('load view for x2many in one2many', function (assert) {
+ assert.expect(2);
+
+ this.data.turtle.records[1].product_id = 37;
+ this.data.partner.records[0].turtles = [2, 3];
+ this.data.partner.records[2].turtles = [1, 3];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group>' +
+ '<field name="int_field"/>' +
+ '<field name="turtles">' +
+ '<form string="Turtles">' +
+ '<group>' +
+ '<field name="product_id"/>' +
+ '<field name="partner_ids"/>' +
+ '</group>' +
+ '</form>' +
+ '<tree>' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</form>',
+ res_id: 1,
+ archs: {
+ "partner,false,list": '<tree string="Partners"><field name="display_name"/></tree>',
+ },
+ });
+
+ assert.strictEqual(form.$('.o_data_row').length, 2,
+ 'should display the 2 turtles');
+
+ form.$('.o_data_row:first').click();
+
+ assert.strictEqual($('.modal .o_field_widget[name="partner_ids"] .o_list_view').length, 1,
+ 'should display many2many list view in the modal');
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many (who contains a one2many) with tree view and without form view', function (assert) {
+ assert.expect(1);
+
+ // avoid error in _postprocess
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group>' +
+ '<field name="turtles">' +
+ '<tree>' +
+ '<field name="partner_ids"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</form>',
+ res_id: 1,
+ archs: {
+ "turtle,false,form": '<form string="Turtles"><field name="turtle_foo"/></form>',
+ },
+ });
+
+ form.$('.o_data_row:first').click();
+
+ assert.strictEqual($('.modal .o_field_widget[name="turtle_foo"]').text(), 'blip',
+ 'should open the modal and display the form field');
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many with x2many in form view (but not in list view)', function (assert) {
+ assert.expect(1);
+
+ // avoid error when saving the edited related record (because the
+ // related x2m field is unknown in the inline list view)
+ // also ensure that the changes are correctly saved
+
+ this.data.turtle.fields.o2m = { string: "o2m", type: "one2many", relation: 'user' };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group>' +
+ '<field name="turtles">' +
+ '<tree>' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</form>',
+ res_id: 1,
+ archs: {
+ "turtle,false,form": '<form string="Turtles">' +
+ '<field name="partner_ids" widget="many2many_tags"/>' +
+ '</form>',
+ },
+ viewOptions: {
+ mode: 'edit',
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ assert.deepEqual(args.args[1].turtles, [[1, 2, {
+ partner_ids: [[6, false, [2, 4, 1]]],
+ }]]);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ form.$('.o_data_row:first').click(); // edit first record
+
+ var $input = $('.modal .o_field_many2manytags input');
+ $input.click(); // opens the dropdown
+ $input.autocomplete('widget').find('li').click(); // add 'first record'
+
+ // add a many2many tag and save
+ $('.modal .o_field_x2many_list_row_add a').click();
+ $('.modal .o_field_widget[name=name]').val('test').trigger('input');
+ $('.modal .modal-footer .btn-primary').click(); // save
+
+ form.$buttons.find('.o_form_button_save').click();
+
+ form.destroy();
+ });
+
+ QUnit.test('many2many list in a one2many opened by a many2one', function (assert) {
+ assert.expect(1);
+
+ this.data.turtle.records[1].turtle_trululu = 2;
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree editable="bottom">' +
+ '<field name="turtle_trululu"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ archs: {
+ "partner,false,form": '<form string="P">' +
+ '<field name="timmy"/>' +
+ '</form>',
+ "partner_type,false,list": '<tree editable="bottom">' +
+ '<field name="display_name"/>' +
+ '</tree>',
+ "partner_type,false,search": '<search>' +
+ '</search>',
+ },
+ viewOptions: {
+ mode: 'edit',
+ },
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/call_kw/partner/get_formview_id') {
+ return $.when(false);
+ }
+ if (args.method === 'write') {
+ assert.deepEqual(args.args[1].timmy, [[6, false, [12]]],
+ 'should properly write ids');
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // edit the first partner in the one2many partner form view
+ form.$('.o_data_row:first td.o_data_cell').click();
+ // open form view for many2one
+ form.$('.o_external_button').click();
+
+ // click on add, to add a new partner in the m2m
+ $('.modal .o_field_x2many_list_row_add a').click();
+
+ // select the partner_type 'gold' (this closes the 2nd modal)
+ $('.modal td:contains(gold)').click();
+
+ // confirm the changes in the modal
+ $('.modal .modal-footer .btn-primary').click();
+
+ form.$buttons.find('.o_form_button_save').click();
+ form.destroy();
+ });
+
+ QUnit.test('nested x2many default values', function (assert) {
+ assert.expect(3);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree editable="top">' +
+ '<field name="partner_ids" widget="many2many_tags"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.model === 'partner' && args.method === 'default_get') {
+ return $.when({
+ turtles: [
+ [0, 0, { partner_ids: [[6, 0, [4]]] }],
+ [0, 0, { partner_ids: [[6, 0, [1]]] }],
+ ],
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual(form.$('.o_list_view .o_data_row').length, 2,
+ "one2many list should contain 2 rows");
+ assert.strictEqual(form.$('.o_list_view .o_field_many2manytags[name="partner_ids"] .badge').length, 2,
+ "m2mtags should contain two tags");
+ assert.strictEqual(form.$('.o_list_view .o_field_many2manytags[name="partner_ids"] .o_badge_text').text(),
+ 'aaafirst record', "tag names should have been correctly loaded");
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many (who contains display_name) with tree view and without form view', function (assert) {
+ assert.expect(1);
+
+ // avoid error in _fetchX2Manys
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group>' +
+ '<field name="turtles">' +
+ '<tree>' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</form>',
+ res_id: 1,
+ archs: {
+ "turtle,false,form": '<form string="Turtles"><field name="turtle_foo"/></form>',
+ },
+ });
+
+ form.$('.o_data_row:first').click();
+
+ assert.strictEqual($('.modal .o_field_widget[name="turtle_foo"]').text(), 'blip',
+ 'should open the modal and display the form field');
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many field with virtual ids', function (assert) {
+ assert.expect(11);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<notebook>' +
+ '<page>' +
+ '<field name="p" mode="kanban">' +
+ '<kanban>' +
+ '<templates>' +
+ '<t t-name="kanban-box">' +
+ '<div class="oe_kanban_details">' +
+ '<div class="o_test_id">' +
+ '<field name="id"/>' +
+ '</div>' +
+ '<div class="o_test_foo">' +
+ '<field name="foo"/>' +
+ '</div>' +
+ '</div>' +
+ '</t>' +
+ '</templates>' +
+ '</kanban>' +
+ '</field>' +
+ '</page>' +
+ '</notebook>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ archs: {
+ 'partner,false,form': '<form string="Associated partners">' +
+ '<field name="foo"/>' +
+ '</form>',
+ },
+ res_id: 4,
+ });
+
+ assert.strictEqual(form.$('.o_field_widget .o_kanban_view').length, 1,
+ "should have one inner kanban view for the one2many field");
+ assert.strictEqual(form.$('.o_field_widget .o_kanban_view .o_kanban_record:not(.o_kanban_ghost)').length, 0,
+ "should not have kanban records yet");
+
+ // // switch to edit mode and create a new kanban record
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_widget .o-kanban-button-new').click();
+
+ // save & close the modal
+ assert.strictEqual($('.modal-content input.o_field_widget').val(), 'My little Foo Value',
+ "should already have the default value for field foo");
+ $('.modal-content .btn-primary').first().click();
+
+ assert.strictEqual(form.$('.o_field_widget .o_kanban_view').length, 1,
+ "should have one inner kanban view for the one2many field");
+ assert.strictEqual(form.$('.o_field_widget .o_kanban_view .o_kanban_record:not(.o_kanban_ghost)').length, 1,
+ "should now have one kanban record");
+ assert.strictEqual(form.$('.o_field_widget .o_kanban_view .o_kanban_record:not(.o_kanban_ghost) .o_test_id').text(),
+ '', "should not have a value for the id field");
+ assert.strictEqual(form.$('.o_field_widget .o_kanban_view .o_kanban_record:not(.o_kanban_ghost) .o_test_foo').text(),
+ 'My little Foo Value', "should have a value for the foo field");
+
+ // save the view to force a create of the new record in the one2many
+ form.$buttons.find('.o_form_button_save').click();
+ assert.strictEqual(form.$('.o_field_widget .o_kanban_view').length, 1,
+ "should have one inner kanban view for the one2many field");
+ assert.strictEqual(form.$('.o_field_widget .o_kanban_view .o_kanban_record:not(.o_kanban_ghost)').length, 1,
+ "should now have one kanban record");
+ assert.notEqual(form.$('.o_field_widget .o_kanban_view .o_kanban_record:not(.o_kanban_ghost) .o_test_id').text(),
+ '', "should now have a value for the id field");
+ assert.strictEqual(form.$('.o_field_widget .o_kanban_view .o_kanban_record:not(.o_kanban_ghost) .o_test_foo').text(),
+ 'My little Foo Value', "should still have a value for the foo field");
+
+ form.destroy();
+ });
+
+ QUnit.test('focusing fields in one2many list', function (assert) {
+ assert.expect(2);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group>' +
+ '<field name="turtles">' +
+ '<tree editable="top">' +
+ '<field name="turtle_foo"/>' +
+ '<field name="turtle_int"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '<field name="foo"/>' +
+ '</form>',
+ res_id: 1,
+ });
+ form.$buttons.find('.o_form_button_edit').click();
+
+ form.$('.o_data_row:first td:first').click();
+ assert.strictEqual(form.$('input[name="turtle_foo"]')[0], document.activeElement,
+ "turtle foo field should have focus");
+
+ form.$('input[name="turtle_foo"]').trigger({ type: 'keydown', which: $.ui.keyCode.TAB });
+ assert.strictEqual(form.$('input[name="turtle_int"]')[0], document.activeElement,
+ "turtle int field should have focus");
+ form.destroy();
+ });
+
+ QUnit.test('one2many list editable = top', function (assert) {
+ assert.expect(6);
+
+ this.data.turtle.fields.turtle_foo.default = "default foo turtle";
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group>' +
+ '<field name="turtles">' +
+ '<tree editable="top">' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ var commands = args.args[1].turtles;
+ assert.strictEqual(commands[0][0], 0,
+ "first command is a create");
+ assert.strictEqual(commands[1][0], 4,
+ "second command is a link to");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ form.$buttons.find('.o_form_button_edit').click();
+
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "should start with one data row");
+
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ assert.strictEqual(form.$('.o_data_row').length, 2,
+ "should have 2 data rows");
+ assert.strictEqual(form.$('tr.o_data_row:first input').val(), 'default foo turtle',
+ "first row should be the new value");
+ assert.ok(form.$('tr.o_data_row:first').hasClass('o_selected_row'),
+ "first row should be selected");
+
+ form.$buttons.find('.o_form_button_save').click();
+ form.destroy();
+ });
+
+ QUnit.test('one2many list editable = bottom', function (assert) {
+ assert.expect(6);
+ this.data.turtle.fields.turtle_foo.default = "default foo turtle";
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group>' +
+ '<field name="turtles">' +
+ '<tree editable="bottom">' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ var commands = args.args[1].turtles;
+ assert.strictEqual(commands[0][0], 4,
+ "first command is a link to");
+ assert.strictEqual(commands[1][0], 0,
+ "second command is a create");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ form.$buttons.find('.o_form_button_edit').click();
+
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "should start with one data row");
+
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ assert.strictEqual(form.$('.o_data_row').length, 2,
+ "should have 2 data rows");
+ assert.strictEqual(form.$('tr.o_data_row:eq(1) input').val(), 'default foo turtle',
+ "second row should be the new value");
+ assert.ok(form.$('tr.o_data_row:eq(1)').hasClass('o_selected_row'),
+ "second row should be selected");
+
+ form.$buttons.find('.o_form_button_save').click();
+ form.destroy();
+ });
+
+ QUnit.test('x2many fields use their "mode" attribute', function (assert) {
+ assert.expect(1);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<group>' +
+ '<field mode="kanban" name="turtles">' +
+ '<tree>' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '<kanban>' +
+ '<templates>' +
+ '<t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="turtle_int"/>' +
+ '</div>' +
+ '</t>' +
+ '</templates>' +
+ '</kanban>' +
+ '</field>' +
+ '</group>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ assert.strictEqual(form.$('.o_field_one2many .o_kanban_view').length, 1,
+ "should have rendered a kanban view");
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many list editable, no onchange when required field is not set', function (assert) {
+ assert.expect(7);
+
+ this.data.turtle.fields.turtle_foo.required = true;
+ this.data.partner.onchanges = {
+ turtles: function (obj) {
+ obj.int_field = obj.turtles.length;
+ },
+ };
+ this.data.partner.records[0].int_field = 0;
+ this.data.partner.records[0].turtles = [];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="int_field"/>' +
+ '<field name="turtles">' +
+ '<tree editable="top">' +
+ '<field name="turtle_int"/>' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super.apply(this, arguments);
+ },
+ res_id: 1,
+ });
+ form.$buttons.find('.o_form_button_edit').click();
+
+ assert.strictEqual(form.$('.o_field_widget[name="int_field"]').val(), "0",
+ "int_field should start with value 0");
+ form.$('.o_field_x2many_list_row_add a').click();
+ assert.strictEqual(form.$('.o_field_widget[name="int_field"]').val(), "0",
+ "int_field should still be 0 (no onchange should have been done yet");
+
+ assert.verifySteps(['read', 'default_get'], "no onchange should have been applied");
+
+ form.$('.o_field_widget[name="turtle_foo"]').val("some text").trigger('input');
+ assert.strictEqual(form.$('.o_field_widget[name="int_field"]').val(), "1",
+ "int_field should now be 1 (the onchange should have been done");
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many list editable: trigger onchange when row is valid', function (assert) {
+ // should omit require fields that aren't in the view as they (obviously)
+ // have no value, when checking the validity of required fields
+ // shouldn't consider numerical fields with value 0 as unset
+ assert.expect(12);
+
+ this.data.turtle.fields.turtle_foo.required = true;
+ this.data.turtle.fields.turtle_qux.required = true; // required field not in the view
+ this.data.turtle.fields.turtle_bar.required = true; // required boolean field with no default
+ delete this.data.turtle.fields.turtle_bar.default;
+ this.data.turtle.fields.turtle_int.required = true; // required int field (default 0)
+ this.data.turtle.fields.turtle_int.default = 0;
+ this.data.turtle.fields.partner_ids.required = true; // required many2many
+ this.data.partner.onchanges = {
+ turtles: function (obj) {
+ obj.int_field = obj.turtles.length;
+ },
+ };
+ this.data.partner.records[0].int_field = 0;
+ this.data.partner.records[0].turtles = [];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="int_field"/>' +
+ '<field name="turtles"/>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super.apply(this, arguments);
+ },
+ archs: {
+ 'turtle,false,list': '<tree editable="top">' +
+ '<field name="turtle_qux"/>' +
+ '<field name="turtle_bar"/>' +
+ '<field name="turtle_int"/>' +
+ '<field name="turtle_foo"/>' +
+ '<field name="partner_ids" widget="many2many_tags"/>' +
+ '</tree>',
+ },
+ res_id: 1,
+ });
+ form.$buttons.find('.o_form_button_edit').click();
+
+ assert.strictEqual(form.$('.o_field_widget[name="int_field"]').val(), "0",
+ "int_field should start with value 0");
+
+ // add a new row (which is invalid at first)
+ form.$('.o_field_x2many_list_row_add a').click();
+ assert.strictEqual(form.$('.o_field_widget[name="int_field"]').val(), "0",
+ "int_field should still be 0 (no onchange should have been done yet)");
+ assert.verifySteps(['load_views', 'read', 'default_get'], "no onchange should have been applied");
+
+ // fill turtle_foo field
+ form.$('.o_field_widget[name="turtle_foo"]').val("some text").trigger('input');
+ assert.strictEqual(form.$('.o_field_widget[name="int_field"]').val(), "0",
+ "int_field should still be 0 (no onchange should have been done yet)");
+ assert.verifySteps(['load_views', 'read', 'default_get'], "no onchange should have been applied");
+
+ // fill partner_ids field with a tag (all required fields will then be set)
+ var $m2mInput = form.$('.o_field_widget[name=partner_ids] input');
+ $m2mInput.click();
+ $m2mInput.autocomplete('widget').find('li:first()').click();
+ assert.strictEqual(form.$('.o_field_widget[name="int_field"]').val(), "1",
+ "int_field should now be 1 (the onchange should have been done");
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many list editable: \'required\' modifiers is properly working', function (assert) {
+ assert.expect(3);
+
+ this.data.partner.onchanges = {
+ turtles: function (obj) {
+ obj.int_field = obj.turtles.length;
+ },
+ };
+
+ this.data.partner.records[0].turtles = [];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="int_field"/>' +
+ '<field name="turtles">' +
+ '<tree editable="top">' +
+ '<field name="turtle_foo" required="1"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+ form.$buttons.find('.o_form_button_edit').click();
+
+ assert.strictEqual(form.$('.o_field_widget[name="int_field"]').val(), "10",
+ "int_field should start with value 10");
+
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ assert.strictEqual(form.$('.o_field_widget[name="int_field"]').val(), "10",
+ "int_field should still be 10 (no onchange, because line is not valid)");
+
+ // fill turtle_foo field
+ form.$('.o_field_widget[name="turtle_foo"]').val("some text").trigger('input');
+
+ assert.strictEqual(form.$('.o_field_widget[name="int_field"]').val(), "1",
+ "int_field should be 1 (onchange triggered, because line is now valid)");
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many list editable: \'required\' modifiers is properly working, part 2', function (assert) {
+ assert.expect(3);
+
+ this.data.partner.onchanges = {
+ turtles: function (obj) {
+ obj.int_field = obj.turtles.length;
+ },
+ };
+
+ this.data.partner.records[0].turtles = [];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="int_field"/>' +
+ '<field name="turtles">' +
+ '<tree editable="top">' +
+ '<field name="turtle_int"/>' +
+ '<field name="turtle_foo" attrs=\'{"required": [["turtle_int", "=", 0]]}\'/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+ form.$buttons.find('.o_form_button_edit').click();
+
+ assert.strictEqual(form.$('.o_field_widget[name="int_field"]').val(), "10",
+ "int_field should start with value 10");
+
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ assert.strictEqual(form.$('.o_field_widget[name="int_field"]').val(), "10",
+ "int_field should still be 10 (no onchange, because line is not valid)");
+
+ // fill turtle_int field
+ form.$('.o_field_widget[name="turtle_int"]').val("1").trigger('input');
+
+ assert.strictEqual(form.$('.o_field_widget[name="int_field"]').val(), "1",
+ "int_field should be 1 (onchange triggered, because line is now valid)");
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many list editable: add new line before onchange returns', function (assert) {
+ // If the user adds a new row (with a required field with onchange), selects
+ // a value for that field, then adds another row before the onchange returns,
+ // the editable list must wait for the onchange to return before trying to
+ // unselect the first row, otherwise it will be detected as invalid.
+ assert.expect(7);
+
+ this.data.turtle.onchanges = {
+ turtle_trululu: function () { },
+ };
+
+ var def;
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree editable="bottom">' +
+ '<field name="turtle_trululu" required="1"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'onchange') {
+ return $.when(def).then(_.constant(result));
+ }
+ return result;
+ },
+ });
+
+ // add a first line but hold the onchange back
+ form.$('.o_field_x2many_list_row_add a').click();
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "should have created the first row immediately");
+ def = $.Deferred();
+ form.$('.o_field_many2one input').click();
+ form.$('.o_field_many2one input').autocomplete('widget').find('a').first().click();
+
+ // try to add a second line and check that it is correctly waiting
+ // for the onchange to return
+ form.$('.o_field_x2many_list_row_add a').click();
+ assert.strictEqual($('.modal').length, 0, "no modal should be displayed");
+ assert.strictEqual($('.o_field_invalid').length, 0,
+ "no field should be marked as invalid");
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "should wait for the onchange to create the second row");
+ assert.ok(form.$('.o_data_row').hasClass('o_selected_row'),
+ "first row should still be in edition");
+
+ // resolve the onchange def
+ def.resolve();
+ assert.strictEqual(form.$('.o_data_row').length, 2,
+ "second row should now have been created");
+ assert.notOk(form.$('.o_data_row:first').hasClass('o_selected_row'),
+ "first row should no more be in edition");
+
+ form.destroy();
+ });
+
+ QUnit.test('editable list: multiple clicks on Add an item do not create invalid rows', function (assert) {
+ assert.expect(3);
+
+ this.data.turtle.onchanges = {
+ turtle_trululu: function () { },
+ };
+
+ var def;
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree editable="bottom">' +
+ '<field name="turtle_trululu" required="1"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'onchange') {
+ return $.when(def).then(_.constant(result));
+ }
+ return result;
+ },
+ });
+
+ // click twice to add a new line
+ def = $.Deferred();
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ assert.strictEqual(form.$('.o_data_row').length, 0,
+ "no row should have been created yet (waiting for the onchange)");
+
+ // resolve the onchange def
+ def.resolve();
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "only one row should have been created");
+ assert.ok(form.$('.o_data_row:first').hasClass('o_selected_row'),
+ "the created row should be in edition");
+
+ form.destroy();
+ });
+
+ QUnit.test('editable list: value reset by an onchange', function (assert) {
+ // this test reproduces a subtle behavior that may occur in a form view:
+ // the user adds a record in a one2many field, and directly clicks on a
+ // datetime field of the form view which has an onchange, which totally
+ // overrides the value of the one2many (commands 5 and 0). The handler
+ // that switches the edited row to readonly is then called after the
+ // new value of the one2many field is applied (the one returned by the
+ // onchange), so the row that must go to readonly doesn't exist anymore.
+ assert.expect(2);
+
+ this.data.partner.onchanges = {
+ datetime: function (obj) {
+ obj.turtles = [[5], [0, 0, { display_name: 'new' }]];
+ },
+ };
+
+ var def;
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="datetime"/>' +
+ '<field name="turtles">' +
+ '<tree editable="bottom">' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'onchange') {
+ return $.when(def).then(_.constant(result));
+ }
+ return result;
+ },
+ });
+
+ // trigger the two onchanges
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('.o_data_row .o_field_widget').val('a name').trigger('input');
+ def = $.Deferred();
+ form.$('.o_datepicker_input').click(); // focusout o2m and set value to today
+ var dateTimeVal = fieldUtils.format.datetime(moment(), { timezone: false });
+ form.$('.o_datepicker_input').val(dateTimeVal).trigger('change');
+
+ // resolve the onchange def
+ def.resolve();
+
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "should have one record in the o2m");
+ assert.strictEqual(form.$('.o_data_row .o_data_cell').text(), 'new',
+ "should be the record created by the onchange");
+
+ form.destroy();
+ });
+
+ QUnit.test('editable list: onchange that returns a warning', function (assert) {
+ assert.expect(5);
+
+ this.data.turtle.onchanges = {
+ display_name: function () { },
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree editable="bottom">' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ assert.step(args.method);
+ return $.when({
+ value: {},
+ warning: {
+ title: "Warning",
+ message: "You must first select a partner"
+ },
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+ viewOptions: {
+ mode: 'edit',
+ },
+ intercepts: {
+ warning: function () {
+ assert.step('warning');
+ },
+ },
+ });
+
+ // add a line (this should trigger an onchange and a warning)
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ // check if 'Add an item' still works (this should trigger an onchange
+ // and a warning again)
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ assert.verifySteps(['onchange', 'warning', 'onchange', 'warning']);
+
+ form.destroy();
+ });
+
+ QUnit.test('editable list: contexts are correctly sent', function (assert) {
+ assert.expect(5);
+
+ this.data.partner.records[0].timmy = [12];
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="foo"/>' +
+ '<field name="timmy" context="{\'key\': parent.foo}">' +
+ '<tree editable="top">' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'read' && args.model === 'partner') {
+ assert.deepEqual(args.kwargs.context, {
+ active_field: 2,
+ bin_size: true,
+ someKey: 'some value',
+ }, "sent context should be correct");
+ }
+ if (args.method === 'read' && args.model === 'partner_type') {
+ assert.deepEqual(args.kwargs.context, {
+ key: 'yop',
+ active_field: 2,
+ someKey: 'some value',
+ }, "sent context should be correct");
+ }
+ if (args.method === 'write') {
+ assert.deepEqual(args.kwargs.context, {
+ active_field: 2,
+ someKey: 'some value',
+ }, "sent context should be correct");
+ }
+ return this._super.apply(this, arguments);
+ },
+ session: {
+ user_context: { someKey: 'some value' },
+ },
+ viewOptions: {
+ mode: 'edit',
+ context: { active_field: 2 },
+ },
+ res_id: 1,
+ });
+
+ form.$('.o_data_cell:first').click();
+ form.$('.o_field_widget[name=display_name]').val('abc').trigger('input');
+ form.$buttons.find('.o_form_button_save').click();
+
+ form.destroy();
+ });
+
+ QUnit.test('resetting invisible one2manys', function (assert) {
+ assert.expect(3);
+
+ this.data.partner.records[0].turtles = [];
+ this.data.partner.onchanges.foo = function (obj) {
+ obj.turtles = [[5], [4, 1]];
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="foo"/>' +
+ '<field name="turtles" invisible="1"/>' +
+ '</form>',
+ viewOptions: {
+ mode: 'edit',
+ },
+ res_id: 1,
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ form.$('input[name="foo"]').val('abcd').trigger('input');
+ assert.verifySteps(['read', 'onchange']);
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many: onchange that returns unknow field in list, but not in form', function (assert) {
+ assert.expect(5);
+
+ this.data.partner.onchanges = {
+ name: function () { },
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="name"/>' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '<form string="Partners">' +
+ '<field name="display_name"/>' +
+ '<field name="timmy" widget="many2many_tags"/>' +
+ '</form>' +
+ '</field>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ return $.when({
+ value: {
+ p: [[5], [0, 0, { display_name: 'new', timmy: [[5], [4, 12]] }]],
+ },
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "the one2many should contain one row");
+ assert.strictEqual(form.$('.o_field_widget[name="timmy"]').length, 0,
+ "timmy should not be displayed in the list view");
+
+ form.$('.o_data_row td:first').click(); // open the record
+
+ assert.strictEqual($('.modal .o_field_many2manytags[name="timmy"]').length, 1,
+ "timmy should be displayed in the form view");
+ assert.strictEqual($('.modal .o_field_many2manytags[name="timmy"] .badge').length, 1,
+ "m2mtags should contain one tag");
+ assert.strictEqual($('.modal .o_field_many2manytags[name="timmy"] .o_badge_text').text(),
+ 'gold', "tag name should have been correctly loaded");
+
+ form.destroy();
+ });
+
+ QUnit.test('onchange and required fields with override in arch', function (assert) {
+ assert.expect(4);
+
+ this.data.partner.onchanges = {
+ turtles: function (obj) { }
+ };
+ this.data.turtle.fields.turtle_foo.required = true;
+ this.data.partner.records[0].turtles = [];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree editable="bottom">' +
+ '<field name="turtle_int"/>' +
+ '<field name="turtle_foo" required="0"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super.apply(this, arguments);
+ },
+ });
+ form.$buttons.find('.o_form_button_edit').click();
+
+ // triggers an onchange on partner, because the new record is valid
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ assert.verifySteps(['read', 'default_get', 'onchange']);
+ form.destroy();
+ });
+
+ QUnit.test('onchange on a one2many containing a one2many', function (assert) {
+ // the purpose of this test is to ensure that the onchange specs are
+ // correctly and recursively computed
+ assert.expect(1);
+
+ this.data.partner.onchanges = {
+ p: function () { }
+ };
+ var checkOnchange = false;
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<tree><field name="display_name"/></tree>' +
+ '<form>' +
+ '<field name="display_name"/>' +
+ '<field name="p">' +
+ '<tree editable="bottom"><field name="display_name"/></tree>' +
+ '</field>' +
+ '</form>' +
+ '</field>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange' && checkOnchange) {
+ assert.strictEqual(args.args[3]['p.p.display_name'], '',
+ "onchange specs should be computed recursively");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ form.$('.o_field_x2many_list_row_add a').click();
+ $('.modal .o_field_x2many_list_row_add a').click();
+ $('.modal .o_data_cell input').val('new record').trigger('input');
+ checkOnchange = true;
+ $('.modal .modal-footer .btn-primary').click(); // save (should trigger the onchange)
+
+ form.destroy();
+ });
+
+ QUnit.test('editing tabbed one2many (editable=bottom)', function (assert) {
+ assert.expect(12);
+
+ this.data.partner.records[0].turtles = [];
+ for (var i = 0; i < 42; i++) {
+ var id = 100 + i;
+ this.data.turtle.records.push({ id: id, turtle_foo: 'turtle' + (id - 99) });
+ this.data.partner.records[0].turtles.push(id);
+ }
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="turtles">' +
+ '<tree editable="bottom">' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ if (args.method === 'write') {
+ assert.strictEqual(args.args[1].turtles[40][0], 0, 'should send a create command');
+ assert.deepEqual(args.args[1].turtles[40][2], { turtle_foo: 'rainbow dash' });
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ assert.strictEqual(form.$('tr.o_data_row').length, 41,
+ "should have 41 data rows on the current page");
+ assert.ok(form.$('tr.o_data_row').last().hasClass('o_selected_row'),
+ "last row should be selected");
+
+ form.$('.o_data_row input[name="turtle_foo"]').val('rainbow dash').trigger('input');
+ form.$buttons.find('.o_form_button_save').click();
+
+ assert.strictEqual(form.$('tr.o_data_row').length, 40,
+ "should have 40 data rows on the current page");
+
+ assert.verifySteps(['read', 'read', 'default_get', 'write', 'read', 'read']);
+ form.destroy();
+ });
+
+ QUnit.test('editing tabbed one2many (editable=bottom), again...', function (assert) {
+ assert.expect(1);
+
+ this.data.partner.records[0].turtles = [];
+ for (var i = 0; i < 9; i++) {
+ var id = 100 + i;
+ this.data.turtle.records.push({ id: id, turtle_foo: 'turtle' + (id - 99) });
+ this.data.partner.records[0].turtles.push(id);
+ }
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree editable="bottom" limit="3">' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+
+
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('.o_data_row input[name="turtle_foo"]').val('rainbow dash').trigger('input');
+ form.$('.o_x2m_control_panel .o_pager_next').click();
+ form.$('.o_x2m_control_panel .o_pager_next').click();
+
+ assert.strictEqual(form.$('tr.o_data_row').length, 3,
+ "should have 3 data rows on the current page");
+ form.destroy();
+ });
+
+ QUnit.test('editing tabbed one2many (editable=top)', function (assert) {
+ assert.expect(15);
+
+ this.data.partner.records[0].turtles = [];
+ this.data.turtle.fields.turtle_foo.default = "default foo";
+ for (var i = 0; i < 42; i++) {
+ var id = 100 + i;
+ this.data.turtle.records.push({ id: id, turtle_foo: 'turtle' + (id - 99) });
+ this.data.partner.records[0].turtles.push(id);
+ }
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="turtles">' +
+ '<tree editable="top">' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ if (args.method === 'write') {
+ assert.strictEqual(args.args[1].turtles[40][0], 0);
+ assert.deepEqual(args.args[1].turtles[40][2], { turtle_foo: 'rainbow dash' });
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_pager_next').click();
+
+ assert.strictEqual(form.$('tr.o_data_row').length, 2,
+ "should have 2 data rows on the current page");
+
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ assert.strictEqual(form.$('tr.o_data_row').length, 3,
+ "should have 3 data rows on the current page (2 records and the created line)");
+
+ assert.ok(form.$('tr.o_data_row').first().hasClass('o_selected_row'),
+ "first row should be selected");
+
+ assert.strictEqual(form.$('tr.o_data_row input').val(), 'default foo',
+ "selected input should have correct string");
+
+ form.$('.o_data_row input[name="turtle_foo"]').val('rainbow dash').trigger('input');
+ form.$buttons.find('.o_form_button_save').click();
+
+ assert.strictEqual(form.$('tr.o_data_row').length, 40,
+ "should have 40 data rows on the current page");
+
+ assert.verifySteps(['read', 'read', 'read', 'default_get', 'write', 'read', 'read']);
+ form.destroy();
+ });
+
+ QUnit.test('one2many field: change value before pending onchange returns', function (assert) {
+ var done = assert.async();
+ assert.expect(2);
+
+ var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
+
+ this.data.partner.onchanges = {
+ int_field: function () { }
+ };
+ var def;
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<tree editable="bottom">' +
+ '<field name="int_field"/>' +
+ '<field name="trululu"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'onchange') {
+ // delay the onchange RPC
+ return $.when(def).then(_.constant(result));
+ }
+ return result;
+ },
+ });
+
+ form.$('.o_field_x2many_list_row_add a').click();
+ def = $.Deferred();
+ form.$('.o_field_widget[name=int_field]')
+ .val('44')
+ .trigger('input');
+
+ var $dropdown = form.$('.o_field_many2one input').autocomplete('widget');
+ // set trululu before onchange
+ form.$('.o_field_many2one input').val('first').trigger('keydown').trigger('keyup');
+ // complete the onchange
+ def.resolve();
+ assert.strictEqual(form.$('.o_field_many2one input').val(), 'first',
+ 'should have kept the new value');
+ concurrency.delay(0).then(function () {
+ // check name_search result
+ assert.strictEqual($dropdown.find('li:not(.o_m2o_dropdown_option)').length, 1,
+ 'autocomplete should contains 1 suggestion');
+
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
+ form.destroy();
+ done();
+ });
+ });
+
+ QUnit.test('focus is correctly reset after an onchange in an x2many', function (assert) {
+ assert.expect(2);
+
+ this.data.partner.onchanges = {
+ int_field: function () { }
+ };
+ var def;
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<tree editable="bottom">' +
+ '<field name="int_field"/>' +
+ '<button string="hello"/>' +
+ '<field name="qux"/>' +
+ '<field name="trululu"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'onchange') {
+ // delay the onchange RPC
+ return $.when(def).then(_.constant(result));
+ }
+ return result;
+ },
+ });
+
+ form.$('.o_field_x2many_list_row_add a').click();
+ def = $.Deferred();
+ form.$('.o_field_widget[name=int_field]')
+ .val('44')
+ .trigger('input')
+ .trigger({ type: 'keydown', which: $.ui.keyCode.TAB });
+ def.resolve();
+
+ assert.strictEqual(document.activeElement, form.$('.o_field_widget[name=qux]')[0],
+ "qux field should have the focus");
+
+ form.$('.o_field_many2one input').click();
+ form.$('.o_field_many2one input').autocomplete('widget').find('a').first().click();
+ assert.strictEqual(form.$('.o_field_many2one input').val(), 'first record',
+ "the one2many field should have the expected value");
+
+ form.destroy();
+ });
+
+ QUnit.test('checkbox in an x2many that triggers an onchange', function (assert) {
+ assert.expect(1);
+
+ this.data.partner.onchanges = {
+ bar: function () { }
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<tree editable="bottom">' +
+ '<field name="bar"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ });
+
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ form.$('.o_field_widget[name=bar] input').click();
+ assert.notOk(form.$('.o_field_widget[name=bar] input').prop('checked'),
+ "the checkbox should be unticked");
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many with default value: edit line to make it invalid', function (assert) {
+ assert.expect(3);
+
+ this.data.partner.fields.p.default = [
+ [0, false, { foo: "coucou", int_field: 5 }],
+ ];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<tree editable="bottom">' +
+ '<field name="foo"/>' +
+ '<field name="int_field"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ });
+
+ // edit the line and enter an invalid value for int_field
+ form.$('.o_data_row .o_data_cell:nth(1)').click();
+ form.$('.o_field_widget[name=int_field]').val('e').trigger('input');
+ form.$el.click(); // try to validate the line
+
+ assert.strictEqual(form.$('.o_data_row.o_selected_row').length, 1,
+ "line should not have been removed and should still be in edition");
+ assert.strictEqual($('.modal').length, 1,
+ "a confirmation dialog should be opened");
+ assert.ok(form.$('.o_field_widget[name=int_field]').hasClass('o_field_invalid'),
+ "should indicate that int_field is invalid");
+
+ form.destroy();
+ });
+
+ QUnit.test('default value for nested one2manys (coming from onchange)', function (assert) {
+ assert.expect(3);
+
+ this.data.partner.onchanges.p = function (obj) {
+ obj.p = [
+ [5],
+ [0, 0, { turtles: [[5], [4, 1]] }], // link record 1 by default
+ ];
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="p">' +
+ '<tree><field name="turtles"/></tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'create') {
+ assert.strictEqual(args.args[0].p[0][0], 0,
+ "should send a command 0 (CREATE) for p");
+ assert.deepEqual(args.args[0].p[0][2], { turtles: [[4, 1, false]] },
+ "should send the correct values");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual(form.$('.o_data_cell').text(), '1 record',
+ "should correctly display the value of the inner o2m");
+
+ form.$buttons.find('.o_form_button_save').click();
+
+ form.destroy();
+ });
+
+ QUnit.test('display correct value after validation error', function (assert) {
+ assert.expect(4);
+
+ this.data.partner.onchanges.turtles = function () { };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="turtles">' +
+ '<tree editable="bottom">' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ if (args.args[1].turtles[0][2].turtle_foo === 'pinky') {
+ // we simulate a validation error. In the 'real' web client,
+ // the server error will be used by the session to display
+ // an error dialog. From the point of view of the basic
+ // model, the deferred is just rejected.
+ return $.Deferred().reject();
+ }
+ }
+ if (args.method === 'write') {
+ assert.deepEqual(args.args[1].turtles[0], [1, 2, { turtle_foo: 'foo' }],
+ 'should send the "good" value');
+ }
+ return this._super.apply(this, arguments);
+ },
+ viewOptions: { mode: 'edit' },
+ res_id: 1,
+ });
+
+ assert.strictEqual(form.$('.o_data_row .o_data_cell:nth(0)').text(), 'blip',
+ "initial text should be correct");
+
+ // click and edit value to 'foo', which will trigger onchange
+ form.$('.o_data_row .o_data_cell:nth(0)').click();
+ form.$('.o_field_widget[name=turtle_foo]').val('foo').trigger('input');
+ form.$el.click();
+ assert.strictEqual(form.$('.o_data_row .o_data_cell:nth(0)').text(), 'foo',
+ "field should have been changed to foo");
+
+ // click and edit value to 'pinky', which trigger a failed onchange
+ form.$('.o_data_row .o_data_cell:nth(0)').click();
+ form.$('.o_field_widget[name=turtle_foo]').val('pinky').trigger('input');
+ form.$el.click();
+
+ assert.strictEqual(form.$('.o_data_row .o_data_cell:nth(0)').text(), 'foo',
+ "turtle_foo text should now be set back to foo");
+
+ // we make sure here that when we save, the values are the current
+ // values displayed in the field.
+ form.$buttons.find('.o_form_button_save').click();
+
+ form.destroy();
+ });
+
+ QUnit.test('propagate context to sub views', function (assert) {
+ assert.expect(5);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="turtles">' +
+ '<tree editable="bottom">' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ assert.strictEqual(args.kwargs.context.flutter, 'shy',
+ 'view context key should be used for every rpcs');
+ return this._super.apply(this, arguments);
+ },
+ viewOptions: { context: { flutter: 'shy' } },
+ });
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('input[name="turtle_foo"]').val('pinky pie').trigger('input');
+ form.$buttons.find('.o_form_button_save').click();
+
+ form.destroy();
+ });
+
+ QUnit.test('onchange on nested one2manys', function (assert) {
+ assert.expect(6);
+
+ this.data.partner.onchanges.display_name = function (obj) {
+ if (obj.display_name) {
+ obj.p = [
+ [5],
+ [0, 0, {
+ display_name: 'test',
+ turtles: [[5], [0, 0, { display_name: 'test nested' }]],
+ }],
+ ];
+ }
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="display_name"/>' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '<form>' +
+ '<field name="turtles">' +
+ '<tree><field name="display_name"/></tree>' +
+ '</field>' +
+ '</form>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'create') {
+ assert.strictEqual(args.args[0].p[0][0], 0,
+ "should send a command 0 (CREATE) for p");
+ assert.strictEqual(args.args[0].p[0][2].display_name, 'test',
+ "should send the correct values");
+ assert.strictEqual(args.args[0].p[0][2].turtles[0][0], 0,
+ "should send a command 0 (CREATE) for turtles");
+ assert.deepEqual(args.args[0].p[0][2].turtles[0][2], { display_name: 'test nested' },
+ "should send the correct values");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ form.$('.o_field_widget[name=display_name]').val('trigger onchange').trigger('input');
+
+ assert.strictEqual(form.$('.o_data_cell').text(), 'test',
+ "should have added the new row to the one2many");
+
+ // open the new subrecord to check the value of the nested o2m, and to
+ // ensure that it will be saved
+ form.$('.o_data_cell:first').click();
+ assert.strictEqual($('.modal .o_data_cell').text(), 'test nested',
+ "should have added the new row to the nested one2many");
+ $('.modal .modal-footer .btn-primary').click();
+
+ form.$buttons.find('.o_form_button_save').click();
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many with multiple pages and sequence field', function (assert) {
+ assert.expect(1);
+
+ this.data.partner.records[0].turtles = [3, 2, 1];
+ this.data.partner.onchanges.turtles = function () { };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree limit="2">' +
+ '<field name="turtle_int" widget="handle"/>' +
+ '<field name="turtle_foo"/>' +
+ '<field name="partner_ids" invisible="1"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ return $.when({
+ value: {
+ turtles: [
+ [5],
+ [1, 1, { turtle_foo: "from onchange", partner_ids: [[5]] }],
+ ]
+ }
+ });
+ }
+ return this._super(route, args);
+ },
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+ form.$('.o_list_record_remove:first button').click();
+ assert.strictEqual(form.$('.o_data_row').text(), 'from onchange',
+ 'onchange has been properly applied');
+ form.destroy();
+ });
+
+ QUnit.test('one2many with multiple pages and sequence field, part2', function (assert) {
+ assert.expect(1);
+
+ this.data.partner.records[0].turtles = [3, 2, 1];
+ this.data.partner.onchanges.turtles = function () { };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree limit="2">' +
+ '<field name="turtle_int" widget="handle"/>' +
+ '<field name="turtle_foo"/>' +
+ '<field name="partner_ids" invisible="1"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ return $.when({
+ value: {
+ turtles: [
+ [5],
+ [1, 1, { turtle_foo: "from onchange id2", partner_ids: [[5]] }],
+ [1, 3, { turtle_foo: "from onchange id3", partner_ids: [[5]] }],
+ ]
+ }
+ });
+ }
+ return this._super(route, args);
+ },
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+ form.$('.o_list_record_remove:first button').click();
+ assert.strictEqual(form.$('.o_data_row').text(), 'from onchange id2from onchange id3',
+ 'onchange has been properly applied');
+ form.destroy();
+ });
+
+ QUnit.test('one2many with sequence field, override default_get, bottom when inline', function (assert) {
+ assert.expect(2);
+
+ this.data.partner.records[0].turtles = [3, 2, 1];
+
+ this.data.turtle.fields.turtle_int.default = 10;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree editable="bottom">' +
+ '<field name="turtle_int" widget="handle"/>' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ // starting condition
+ assert.strictEqual($('.o_data_cell').text(), "blipyopkawa");
+
+ // click add a new line
+ // save the record
+ // check line is at the correct place
+
+ var inputText = 'ninja';
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('.o_input[name="turtle_foo"]').val(inputText).trigger('input');
+ form.$buttons.find('.o_form_button_save').click();
+
+ assert.strictEqual($('.o_data_cell').text(), "blipyopkawa" + inputText);
+ form.destroy();
+ });
+
+ QUnit.test('one2many with sequence field, override default_get, top when inline', function (assert) {
+ assert.expect(2);
+
+ this.data.partner.records[0].turtles = [3, 2, 1];
+
+ this.data.turtle.fields.turtle_int.default = 10;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree editable="top">' +
+ '<field name="turtle_int" widget="handle"/>' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ // starting condition
+ assert.strictEqual($('.o_data_cell').text(), "blipyopkawa");
+
+ // click add a new line
+ // save the record
+ // check line is at the correct place
+
+ var inputText = 'ninja';
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('.o_input[name="turtle_foo"]').val(inputText).trigger('input');
+ form.$buttons.find('.o_form_button_save').click();
+
+ assert.strictEqual($('.o_data_cell').text(), inputText + "blipyopkawa");
+ form.destroy();
+ });
+
+ QUnit.test('one2many with sequence field, override default_get, bottom when popup', function (assert) {
+ assert.expect(3);
+
+ this.data.partner.records[0].turtles = [3, 2, 1];
+
+ this.data.turtle.fields.turtle_int.default = 10;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree>' +
+ '<field name="turtle_int" widget="handle"/>' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '<form>' +
+ // NOTE: at some point we want to fix this in the framework so that an invisible field is not required.
+ '<field name="turtle_int" invisible="1"/>' +
+ '<field name="turtle_foo"/>' +
+ '</form>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ // starting condition
+ assert.strictEqual($('.o_data_cell').text(), "blipyopkawa");
+
+ // click add a new line
+ // save the record
+ // check line is at the correct place
+
+ var inputText = 'ninja';
+ $('.o_field_x2many_list_row_add a').click();
+ $('.o_input[name="turtle_foo"]').val(inputText).trigger('input');
+ $('.modal .modal-footer .btn-primary:first').click();
+
+ assert.strictEqual($('.o_data_cell').text(), "blipyopkawa" + inputText);
+
+ $('.o_form_button_save').click();
+ assert.strictEqual($('.o_data_cell').text(), "blipyopkawa" + inputText);
+ form.destroy();
+ });
+
+ QUnit.test('one2many with sequence field, override default_get, not last page', function (assert) {
+ assert.expect(1);
+
+ this.data.partner.records[0].turtles = [3, 2, 1];
+
+ this.data.turtle.fields.turtle_int.default = 10;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree limit="2">' +
+ '<field name="turtle_int" widget="handle"/>' +
+ '</tree>' +
+ '<form>' +
+ '<field name="turtle_int"/>' +
+ '</form>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ // click add a new line
+ // check turtle_int for new is the current max of the page
+ $('.o_field_x2many_list_row_add a').click();
+ assert.strictEqual($('.modal .o_input[name="turtle_int"]').val(), '10');
+ form.destroy();
+ });
+
+ QUnit.test('one2many with sequence field, override default_get, last page', function (assert) {
+ assert.expect(1);
+
+ this.data.partner.records[0].turtles = [3, 2, 1];
+
+ this.data.turtle.fields.turtle_int.default = 10;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree limit="4">' +
+ '<field name="turtle_int" widget="handle"/>' +
+ '</tree>' +
+ '<form>' +
+ '<field name="turtle_int"/>' +
+ '</form>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ // click add a new line
+ // check turtle_int for new is the current max of the page +1
+ $('.o_field_x2many_list_row_add a').click();
+ assert.strictEqual($('.modal .o_input[name="turtle_int"]').val(), '22');
+ form.destroy();
+ });
+
+ QUnit.test('one2many with sequence field, fetch name_get from empty list, field text', function (assert) {
+ // There was a bug where a RPC would fail because no route was set.
+ // The scenario is:
+ // - create a new parent model, which has a one2many
+ // - add at least 2 one2many lines which have:
+ // - a handle field
+ // - a many2one, which is not required, and we will leave it empty
+ // - reorder the lines with the handle
+ // -> This will call a resequence, which calls a name_get.
+ // -> With the bug that would fail, if it's ok the test will pass.
+
+ // This test will also make sure lists with
+ // FieldText (turtle_description) can be reordered with a handle.
+ // More specifically this will trigger a reset on a FieldText
+ // while the field is not in editable mode.
+ assert.expect(4);
+
+ this.data.turtle.fields.turtle_int.default = 10;
+ this.data.turtle.fields.product_id.default = 37;
+ this.data.turtle.fields.not_required_product_id = {
+ string: "Product",
+ type: "many2one",
+ relation: 'product'
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree editable="bottom">' +
+ '<field name="turtle_int" widget="handle"/>' +
+ '<field name="turtle_foo"/>' +
+ '<field name="not_required_product_id"/>' +
+ '<field name="turtle_description" widget="text"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ // starting condition
+ assert.strictEqual($('.o_data_cell:nth-child(2)').text(), "");
+
+ var inputText1 = 'relax';
+ var inputText2 = 'max';
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('.o_input[name="turtle_foo"]').val(inputText1).trigger('input');
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('.o_input[name="turtle_foo"]').val(inputText2).trigger('input');
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ assert.strictEqual($('.o_data_cell:nth-child(2)').text(), inputText1 + inputText2);
+
+ var $handles = form.$('.ui-sortable-handle');
+
+ assert.equal($handles.length, 3, 'There should be 3 sequence handlers');
+
+ testUtils.dragAndDrop($handles.eq(1),
+ form.$('tbody tr').first(),
+ { position: 'top' }
+ );
+
+ assert.strictEqual($('.o_data_cell:nth-child(2)').text(), inputText2 + inputText1);
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many with several pages, onchange and default order', function (assert) {
+ // This test reproduces a specific scenario where a one2many is displayed
+ // over several pages, and has a default order such that a record that
+ // would normally be on page 1 is actually on another page. Moreover,
+ // there is an onchange on that one2many which converts all commands 4
+ // (LINK_TO) into commands 1 (UPDATE), which is standard in the ORM.
+ // This test ensures that the record displayed on page 2 is never fully
+ // read.
+ assert.expect(8);
+
+ var data = this.data;
+ data.partner.records[0].turtles = [1, 2, 3];
+ data.turtle.records[0].partner_ids = [1];
+ data.partner.onchanges = {
+ turtles: function (obj) {
+ var res = _.map(obj.turtles, function (command) {
+ if (command[0] === 1) { // already an UPDATE command: do nothing
+ return command;
+ }
+ // convert LINK_TO commands to UPDATE commands
+ var id = command[1];
+ var record = _.findWhere(data.turtle.records, { id: id });
+ return [1, id, _.pick(record, ['turtle_int', 'turtle_foo', 'partner_ids'])];
+ });
+ obj.turtles = [[5]].concat(res);
+ },
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree editable="top" limit="2" default_order="turtle_foo">' +
+ '<field name="turtle_int"/>' +
+ '<field name="turtle_foo" class="foo"/>' +
+ '<field name="partner_ids" widget="many2many_tags"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ var ids = args.method === 'read' ? ' [' + args.args[0] + ']' : '';
+ assert.step(args.method + ids);
+ return this._super.apply(this, arguments);
+ },
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ assert.strictEqual(form.$('.o_data_cell.foo').text(), 'blipkawa',
+ "should display two records out of three, in the correct order");
+
+ // edit turtle_int field of first row
+ form.$('.o_data_cell:first').click();
+ form.$('.o_field_widget[name=turtle_int]').val(3).trigger('input');
+ form.$el.click(); // leave edition
+
+ assert.strictEqual(form.$('.o_data_cell.foo').text(), 'blipkawa',
+ "should still display the same two records");
+
+ assert.verifySteps([
+ 'read [1]', // main record
+ 'read [1,2,3]', // one2many (turtle_foo, all records)
+ 'read [2,3]', // one2many (all fields in view, records of first page)
+ 'read [2,4]', // many2many inside one2many (partner_ids), first page only
+ 'onchange',
+ ]);
+
+ form.destroy();
+ });
+
+ QUnit.test('new record, with one2many with more default values than limit', function (assert) {
+ assert.expect(2);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree limit="2">' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ context: { default_turtles: [1, 2, 3] },
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+ assert.strictEqual(form.$('.o_data_row').text(), 'yopblip',
+ 'data has been properly loaded');
+ form.$buttons.find('.o_form_button_save').click();
+
+ assert.strictEqual(form.$('.o_data_row').text(), 'yopblip',
+ 'data has been properly saved');
+ form.destroy();
+ });
+
+ QUnit.test('add a new line after limit is reached should behave nicely', function (assert) {
+ assert.expect(2);
+
+ this.data.partner.records[0].turtles = [1, 2, 3];
+
+ this.data.partner.onchanges = {
+ turtles: function (obj) {
+ obj.turtles = [
+ [5],
+ [1, 1, { turtle_foo: "yop" }],
+ [1, 2, { turtle_foo: "blip" }],
+ [1, 3, { turtle_foo: "kawa" }],
+ [0, obj.turtles[3][2], { turtle_foo: "abc" }],
+ ];
+ },
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree limit="3" editable="bottom">' +
+ '<field name="turtle_foo" required="1"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ form.$('.o_field_x2many_list_row_add a').click();
+ assert.strictEqual(form.$('.o_data_row').length, 4, 'should have 4 data rows');
+ form.$('.o_input[name="turtle_foo"]').val('a').trigger('input');
+ assert.strictEqual(form.$('.o_data_row').length, 4,
+ 'should still have 4 data rows (the limit is increased to 4)');
+
+ form.destroy();
+ });
+
+ QUnit.test('onchange in a one2many with non inline view on an existing record', function (assert) {
+ assert.expect(6);
+
+ this.data.partner.fields.sequence = { string: 'Sequence', type: 'integer' };
+ this.data.partner.records[0].sequence = 1;
+ this.data.partner.records[1].sequence = 2;
+ this.data.partner.onchanges = { sequence: function () { } };
+
+ this.data.partner_type.fields.partner_ids = { string: "Partner", type: "one2many", relation: 'partner' };
+ this.data.partner_type.records[0].partner_ids = [1, 2];
+
+ var form = testUtils.createView({
+ View: FormView,
+ model: 'partner_type',
+ data: this.data,
+ arch: '<form><field name="partner_ids"/></form>',
+ archs: {
+ 'partner,false,list': '<tree string="Vendors">' +
+ '<field name="sequence" widget="handle"/>' +
+ '<field name="display_name"/>' +
+ '</tree>',
+ },
+ res_id: 12,
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super.apply(this, arguments);
+ },
+ viewOptions: { mode: 'edit' },
+ });
+
+ // swap 2 lines in the one2many
+ testUtils.dragAndDrop(form.$('.ui-sortable-handle:eq(1)'), form.$('tbody tr').first(),
+ { position: 'top' });
+ assert.verifySteps(['load_views', 'read', 'read', 'onchange', 'onchange']);
+ form.destroy();
+ });
+
+ QUnit.test('onchange in a one2many with non inline view on a new record', function (assert) {
+ assert.expect(8);
+
+ this.data.turtle.onchanges = {
+ display_name: function (obj) {
+ if (obj.display_name) {
+ obj.turtle_int = 44;
+ }
+ },
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form><field name="turtles"/></form>',
+ archs: {
+ 'turtle,false,list': '<tree editable="bottom">' +
+ '<field name="display_name"/>' +
+ '<field name="turtle_int"/>' +
+ '</tree>',
+ },
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // add a row and trigger the onchange
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('.o_data_row .o_field_widget[name=display_name]').val('a name').trigger('input');
+
+ assert.strictEqual(form.$('.o_data_row .o_field_widget[name=turtle_int]').val(), "44",
+ "should have triggered the onchange");
+
+ assert.verifySteps([
+ 'load_views', // load sub list
+ 'default_get', // main record
+ 'onchange', // main record
+ 'default_get', // sub record
+ 'onchange', // sub record
+ 'onchange', // edition of display_name of sub record
+ ]);
+
+ form.destroy();
+ });
+
+ QUnit.test('add a line, edit it and "Save & New"', function (assert) {
+ assert.expect(5);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<tree><field name="display_name"/></tree>' +
+ '<form><field name="display_name"/></form>' +
+ '</field>' +
+ '</form>',
+ });
+
+ assert.strictEqual(form.$('.o_data_row').length, 0,
+ "there should be no record in the relation");
+
+ // add a new record
+ form.$('.o_field_x2many_list_row_add a').click();
+ $('.modal .o_field_widget').val('new record').trigger('input');
+ $('.modal .modal-footer .btn-primary:first').click(); // Save & Close
+
+ assert.strictEqual(form.$('.o_data_row .o_data_cell').text(), 'new record',
+ "should display the new record");
+
+ // reopen freshly added record and edit it
+ form.$('.o_data_row .o_data_cell').click();
+ $('.modal .o_field_widget').val('new record edited').trigger('input');
+
+ // save it, and choose to directly create another record
+ $('.modal .modal-footer .btn-primary:nth(1)').click(); // Save & New
+
+ assert.strictEqual($('.modal').length, 1,
+ "the model should still be open");
+ assert.strictEqual($('.modal .o_field_widget').text(), '',
+ "should have cleared the input");
+
+ $('.modal .o_field_widget').val('another new record').trigger('input');
+ $('.modal .modal-footer .btn-primary:first').click(); // Save & Close
+
+ assert.strictEqual(form.$('.o_data_row .o_data_cell').text(),
+ 'new record editedanother new record', "should display the two records");
+
+ form.destroy();
+ });
+
+ QUnit.test('o2m add a line custom control create editable', function (assert) {
+ assert.expect(5);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:
+ '<form string="Partners">' +
+ '<field name="p">' +
+ '<tree editable="bottom">' +
+ '<control>' +
+ '<create string="Add food" context="" />' +
+ '<create string="Add pizza" context="{\'default_display_name\': \'pizza\'}"/>' +
+ '</control>' +
+
+ '<control>' +
+ '<create string="Add pasta" context="{\'default_display_name\': \'pasta\'}"/>' +
+ '</control>' +
+
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '<form>' +
+ '<field name="display_name"/>' +
+ '</form>' +
+ '</field>' +
+ '</form>',
+ });
+
+ // new controls correctly added
+ var $td = form.$('.o_field_x2many_list_row_add');
+ assert.strictEqual($td.length, 1);
+ assert.strictEqual($td.closest('tr').find('td').length, 1);
+ assert.strictEqual($td.text(), "Add foodAdd pizzaAdd pasta");
+
+ // click add food
+ // check it's empty
+ form.$('.o_field_x2many_list_row_add a:eq(0)').click();
+ assert.strictEqual($('.o_data_cell').text(), "");
+
+ // click add pizza
+ // save the modal
+ // check it's pizza
+ form.$('.o_field_x2many_list_row_add a:eq(1)').click();
+ // click add pasta
+ form.$('.o_field_x2many_list_row_add a:eq(2)').click();
+ form.$buttons.find('.o_form_button_save').click();
+ assert.strictEqual($('.o_data_cell').text(), "pizzapasta");
+
+ form.destroy();
+ });
+
+ QUnit.test('o2m add a line custom control create non-editable', function (assert) {
+ assert.expect(6);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:
+ '<form string="Partners">' +
+ '<field name="p">' +
+ '<tree>' +
+ '<control>' +
+ '<create string="Add food" context="" />' +
+ '<create string="Add pizza" context="{\'default_display_name\': \'pizza\'}" />' +
+ '</control>' +
+
+ '<control>' +
+ '<create string="Add pasta" context="{\'default_display_name\': \'pasta\'}" />' +
+ '</control>' +
+
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '<form>' +
+ '<field name="display_name"/>' +
+ '</form>' +
+ '</field>' +
+ '</form>',
+ });
+
+ // new controls correctly added
+ var $td = form.$('.o_field_x2many_list_row_add');
+ assert.strictEqual($td.length, 1);
+ assert.strictEqual($td.closest('tr').find('td').length, 1);
+ assert.strictEqual($td.text(), "Add foodAdd pizzaAdd pasta");
+
+ // click add food
+ // check it's empty
+ form.$('.o_field_x2many_list_row_add a:eq(0)').click();
+ $('.modal .modal-footer .btn-primary:first').click();
+ assert.strictEqual($('.o_data_cell').text(), "");
+
+ // click add pizza
+ // save the modal
+ // check it's pizza
+ form.$('.o_field_x2many_list_row_add a:eq(1)').click();
+ $('.modal .modal-footer .btn-primary:first').click();
+ assert.strictEqual($('.o_data_cell').text(), "pizza");
+
+ // click add pasta
+ // save the whole record
+ // check it's pizzapasta
+ form.$('.o_field_x2many_list_row_add a:eq(2)').click();
+ $('.modal .modal-footer .btn-primary:first').click();
+ assert.strictEqual($('.o_data_cell').text(), "pizzapasta");
+
+ form.destroy();
+ });
+
+ QUnit.test('o2m add a line custom control create align with handle', function (assert) {
+ assert.expect(3);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:
+ '<form string="Partners">' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="int_field" widget="handle"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ });
+
+ // controls correctly added, at one column offset when handle is present
+ var $tr = form.$('.o_field_x2many_list_row_add').closest('tr');
+ assert.strictEqual($tr.find('td').length, 2);
+ assert.strictEqual($tr.find('td:eq(0)').text(), "");
+ assert.strictEqual($tr.find('td:eq(1)').text(), "Add a line");
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many form view with action button', function (assert) {
+ // once the action button is clicked, the record is reloaded (via the
+ // on_close handler, executed because the python method does not return
+ // any action, or an ir.action.act_window_close) ; this test ensures that
+ // it reloads the fields of the opened view (i.e. the form in this case).
+ // See https://github.com/odoo/odoo/issues/24189
+ assert.expect(7);
+
+ var data = this.data;
+ data.partner.records[0].p = [2];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: data,
+ res_id: 1,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<tree><field name="display_name"/></tree>' +
+ '<form>' +
+ '<button type="action" string="Set Timmy"/>' +
+ '<field name="timmy"/>' +
+ '</form>' +
+ '</field>' +
+ '</form>',
+ archs: {
+ 'partner_type,false,list': '<tree><field name="display_name"/></tree>',
+ },
+ intercepts: {
+ execute_action: function (ev) {
+ data.partner.records[1].display_name = 'new name';
+ data.partner.records[1].timmy = [12];
+ ev.data.on_closed();
+ },
+ },
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "there should be one record in the one2many");
+ assert.strictEqual(form.$('.o_data_cell').text(), 'second record',
+ "initial display_name of o2m record should be correct");
+
+ // open one2many record in form view
+ form.$('.o_data_cell:first').click();
+ assert.strictEqual($('.modal .o_form_view').length, 1,
+ "should have opened the form view in a dialog");
+ assert.strictEqual($('.modal .o_form_view .o_data_row').length, 0,
+ "there should be no record in the many2many");
+
+ // click on the action button
+ $('.modal .o_form_view button').click();
+ assert.strictEqual($('.modal .o_data_row').length, 1,
+ "fields in the o2m form view should have been read");
+ assert.strictEqual($('.modal .o_data_cell').text(), 'gold',
+ "many2many subrecord should have been fetched");
+
+ // save the dialog
+ $('.modal .modal-footer .btn-primary').click();
+
+ assert.strictEqual(form.$('.o_data_cell').text(), 'new name',
+ "fields in the o2m list view should have been read as well");
+
+ form.destroy();
+ });
+
+ QUnit.test('onchange affecting inline unopened list view', function (assert) {
+ // when we got onchange result for fields of record that were not
+ // already available because they were in a inline view not already
+ // opened, in a given configuration the change were applied ignoring
+ // existing data, thus a line of a one2many field inside a one2many
+ // field could be duplicated unexplectedly
+ assert.expect(5);
+
+ var numUserOnchange = 0;
+
+ this.data.user.onchanges = {
+ partner_ids: function (obj) {
+ if (numUserOnchange === 0) {
+ // simulate proper server onchange after save of modal with new record
+ obj.partner_ids = [
+ [5],
+ [1, 1, {
+ display_name: 'first record',
+ turtles: [
+ [5],
+ [1, 2, { 'display_name': 'donatello' }],
+ ],
+ }],
+ [1, 2, {
+ display_name: 'second record',
+ turtles: [
+ [5],
+ obj.partner_ids[1][2].turtles[0],
+ ],
+ }],
+ ];
+ }
+ numUserOnchange++;
+ },
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'user',
+ data: this.data,
+ arch: '<form><sheet><group>' +
+ '<field name="partner_ids">' +
+ '<form>' +
+ '<field name="turtles">' +
+ '<tree editable="bottom">' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>' +
+ '<tree>' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group></sheet></form>',
+ res_id: 17,
+ });
+
+ // add a turtle on second partner
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_data_row:eq(1)').click();
+ $('.modal .o_field_x2many_list_row_add a').click();
+ $('.modal input[name="display_name"]').val('michelangelo').change();
+ $('.modal .btn-primary').click();
+ // open first partner so changes from previous action are applied
+ form.$('.o_data_row:eq(0)').click();
+ $('.modal .btn-primary').click();
+ form.$buttons.find('.o_form_button_save').click();
+
+ assert.strictEqual(numUserOnchange, 2,
+ 'there should 2 and only 2 onchange from closing the partner modal');
+
+ form.$('.o_data_row:eq(0)').click();
+ assert.strictEqual($('.modal .o_data_row').length, 1,
+ 'only 1 turtle for first partner');
+ assert.strictEqual($('.modal .o_data_row').text(), 'donatello',
+ 'first partner turtle is donatello');
+ $('.modal .o_form_button_cancel').click();
+
+ form.$('.o_data_row:eq(1)').click();
+ assert.strictEqual($('.modal .o_data_row').length, 1,
+ 'only 1 turtle for second partner');
+ assert.strictEqual($('.modal .o_data_row').text(), 'michelangelo',
+ 'second partner turtle is michelangelo');
+ $('.modal .o_form_button_cancel').click();
+
+ form.destroy();
+ });
+
+ QUnit.test('click on URL should not open the record', function (assert) {
+ assert.expect(2);
+
+ this.data.partner.records[0].turtles = [1];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree>' +
+ '<field name="display_name" widget="email"/>' +
+ '<field name="turtle_foo" widget="url"/>' +
+ '</tree>' +
+ '<form></form>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ form.$('.o_email_cell a').click();
+ assert.strictEqual($('.modal .o_form_view').length, 0,
+ 'click should not open the modal');
+
+ form.$('.o_url_cell a').click();
+ assert.strictEqual($('.modal .o_form_view').length, 0,
+ 'click should not open the modal');
+ form.destroy();
+ });
+
+ QUnit.test('create and edit on m2o in o2m, and press ESCAPE', function (assert) {
+ assert.expect(4);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="turtles">' +
+ '<tree editable="top">' +
+ '<field name="turtle_trululu"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ archs: {
+ 'partner,false,form': '<form><field name="display_name"/></form>',
+ },
+ });
+
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ assert.strictEqual(form.$('.o_selected_row').length, 1,
+ "should have create a new row in edition");
+
+ var $input = form.$('.o_field_widget[name="turtle_trululu"] input');
+ $input.click();
+ $input.autocomplete('widget').find('.o_m2o_dropdown_option').focus().click();
+
+ assert.strictEqual($('.modal .o_form_view').length, 1,
+ "should have opened a form view in a dialog");
+
+ $('.modal .o_form_view .o_field_widget[name=display_name]').trigger($.Event('keydown', {
+ which: $.ui.keyCode.ESCAPE,
+ keyCode: $.ui.keyCode.ESCAPE,
+ }));
+
+ assert.strictEqual($('.modal .o_form_view').length, 0,
+ "should have closed the dialog");
+ assert.strictEqual(form.$('.o_selected_row').length, 1,
+ "new row should still be present");
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many add a line should not crash if orderedResIDs is not set', function (assert) {
+ // There is no assertion, the code will just crash before the bugfix.
+ assert.expect(0);
+
+ this.data.partner.records[0].turtles = [];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<header>' +
+ '<button name="action_invoice_open" type="object" string="Validate" class="oe_highlight"/>' +
+ '</header>' +
+ '<field name="turtles">' +
+ '<tree editable="bottom">' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ viewOptions: {
+ mode: 'edit',
+ },
+ intercepts: {
+ execute_action: function (event) {
+ event.data.on_fail();
+ },
+ },
+ });
+
+ $('button[name="action_invoice_open"]').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.destroy();
+ });
+
+ QUnit.test('one2many shortcut tab should not crash when there is no input widget', function (assert) {
+ assert.expect(2);
+
+ // create a one2many view which has no input (only 1 textarea in this case)
+ 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="text"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ // add a row, fill it, then trigger the tab shortcut
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('.o_input[name="turtle_foo"]')
+ .val('ninja')
+ .trigger('input')
+ .trigger($.Event('keydown', {
+ which: $.ui.keyCode.TAB,
+ keyCode: $.ui.keyCode.TAB,
+ }));
+
+ assert.strictEqual(form.$('.o_field_text').text(), 'blipninja',
+ 'current line should be saved');
+ assert.strictEqual(form.$('textarea.o_field_text').length, 1,
+ 'new line should be created');
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many with onchange, required field, shortcut enter', function (assert) {
+ assert.expect(5);
+
+ this.data.turtle.onchanges = {
+ turtle_foo: function () { },
+ };
+
+ var def;
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree editable="bottom">' +
+ '<field name="turtle_foo" required="1"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'onchange') {
+ return $.when(def).then(_.constant(result));
+ }
+ return result;
+ },
+ // simulate what happens in the client:
+ // the new value isn't notified directly to the model
+ fieldDebounce: 5000,
+ });
+
+ var value = "hello";
+
+ // add a new line
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ // we want to add a delay to simulate an onchange
+ def = $.Deferred();
+
+ // write something in the field
+ var $input = form.$('input[name="turtle_foo"]');
+ $input.val(value).trigger('input');
+
+ // trigger keydown ENTER
+ $input.trigger($.Event('keydown', {
+ keyCode: $.ui.keyCode.ENTER,
+ which: $.ui.keyCode.ENTER,
+ }));
+
+ // check that nothing changed before the onchange finished
+ assert.strictEqual($input.val(), value, "input content shouldn't change");
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "should still contain only one row");
+
+ // unlock onchange
+ def.resolve();
+
+ // check the current line is added with the correct content and a new line is editable
+ assert.strictEqual(form.$('td.o_data_cell').text(), value);
+ assert.strictEqual(form.$('input[name="turtle_foo"]').val(), '');
+ assert.strictEqual(form.$('.o_data_row').length, 2,
+ "should now contain two rows");
+
+ form.destroy();
+ });
+
+ QUnit.test('no deadlock when leaving a one2many line with uncommitted changes', function (assert) {
+ // Before unselecting a o2m line, field widgets are asked to commit their changes (new values
+ // that they wouldn't have sent to the model yet). This test is added alongside a bug fix
+ // ensuring that we don't end up in a deadlock when a widget actually has some changes to
+ // commit at that moment.
+ assert.expect(9);
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="turtles">' +
+ '<tree editable="bottom">' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super.apply(this, arguments);
+ },
+ // we set a fieldDebounce to precisely mock the behavior of the webclient: changes are
+ // not sent to the model at keystrokes, but when the input is left
+ fieldDebounce: 5000,
+ });
+
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ form.$('input').val('some foo value').trigger('input');
+
+ // click to add a second row to unselect the current one, then save
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$buttons.find('.o_form_button_save').click();
+
+ assert.strictEqual(form.$('.o_form_readonly').length, 1,
+ "form view should be in readonly");
+ assert.strictEqual(form.$('.o_data_row').text(), 'some foo value',
+ "foo field should have correct value");
+ assert.verifySteps([
+ 'default_get', // main record
+ 'default_get', // line 1
+ 'default_get', // line 2
+ 'create',
+ 'read', // main record
+ 'read', // line 1
+ ]);
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many with extra field from server not in form', function (assert) {
+ assert.expect(6);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p" >' +
+ '<tree>' +
+ '<field name="datetime"/>' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ archs: {
+ 'partner,false,form': '<form>' +
+ '<field name="display_name"/>' +
+ '</form>'
+ },
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/call_kw/partner/write') {
+ args.args[1].p[0][2].datetime = '2018-04-05 12:00:00';
+ }
+ return this._super.apply(this, arguments);
+ }
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+
+ var x2mList = form.$('.o_field_x2many_list[name=p]');
+
+ // Add a record in the list
+ x2mList.find('.o_field_x2many_list_row_add a').click();
+
+ var modal = $('.modal-lg');
+
+ var nameInput = modal.find('input.o_input[name=display_name]');
+ nameInput.val('michelangelo').trigger('input');
+
+ // Save the record in the modal (though it is still virtual)
+ modal.find('.btn-primary').first().click();
+
+ assert.equal(x2mList.find('.o_data_row').length, 1,
+ 'There should be 1 records in the x2m list');
+
+ var newlyAdded = x2mList.find('.o_data_row').eq(0);
+
+ assert.equal(newlyAdded.find('.o_data_cell').first().text(), '',
+ 'The create_date field should be empty');
+ assert.equal(newlyAdded.find('.o_data_cell').eq(1).text(), 'michelangelo',
+ 'The display name field should have the right value');
+
+ // Save the whole thing
+ form.$buttons.find('.o_form_button_save').click();
+
+ x2mList = form.$('.o_field_x2many_list[name=p]');
+
+ // Redo asserts in RO mode after saving
+ assert.equal(x2mList.find('.o_data_row').length, 1,
+ 'There should be 1 records in the x2m list');
+
+ newlyAdded = x2mList.find('.o_data_row').eq(0);
+
+ assert.equal(newlyAdded.find('.o_data_cell').first().text(), '04/05/2018 12:00:00',
+ 'The create_date field should have the right value');
+ assert.equal(newlyAdded.find('.o_data_cell').eq(1).text(), 'michelangelo',
+ 'The display name field should have the right value');
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many invisible depends on parent field', function (assert) {
+ assert.expect(4);
+
+ this.data.partner.records[0].p = [2];
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ 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" attrs="{\'column_invisible\': [(\'parent.product_id\', \'!=\', False)]}"/>' +
+ '<field name="bar" attrs="{\'column_invisible\': [(\'parent.bar\', \'=\', False)]}"/>' +
+ '</tree>' +
+ '</field>' +
+ '</page>' +
+ '</notebook>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+ 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");
+ form.destroy();
+ });
+
+ QUnit.test('one2many column visiblity depends on onchange of parent field', function (assert) {
+ assert.expect(3);
+
+ this.data.partner.records[0].p = [2];
+ this.data.partner.records[0].bar = false;
+
+ this.data.partner.onchanges.p = function (obj) {
+ // set bar to true when line is added
+ if (obj.p.length > 1 && obj.p[1][2].foo === 'New line') {
+ obj.bar = true;
+ }
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="bar"/>' +
+ '<field name="p">' +
+ '<tree editable="bottom">' +
+ '<field name="foo"/>' +
+ '<field name="int_field" attrs="{\'column_invisible\': [(\'parent.bar\', \'=\', False)]}"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ // bar is false so there should be 1 column
+ assert.strictEqual(form.$('th').length, 1,
+ "should be only 1 column ('foo') in the one2many");
+ assert.strictEqual(form.$('.o_list_view .o_data_row').length, 1, "should contain one row");
+
+ form.$buttons.find('.o_form_button_edit').click();
+
+ // add a new o2m record
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('.o_field_one2many input:first').focus();
+ form.$('.o_field_one2many input:first').val('New line').trigger('input');
+ form.$el.click(); // click outside to save a line
+
+ assert.strictEqual(form.$('th').length, 2, "should be 2 columns('foo' + 'int_field')");
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many column_invisible on view not inline', function (assert) {
+ assert.expect(4);
+
+ this.data.partner.records[0].p = [2];
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ 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"/>' +
+ '</page>' +
+ '</notebook>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ archs: {
+ 'partner,false,list': '<tree>' +
+ '<field name="foo" attrs="{\'column_invisible\': [(\'parent.product_id\', \'!=\', False)]}"/>' +
+ '<field name="bar" attrs="{\'column_invisible\': [(\'parent.bar\', \'=\', False)]}"/>' +
+ '</tree>',
+ },
+ });
+ 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");
+ form.destroy();
+ });
+ });
+});
+});
diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js
index 9c7c6363a40..ed34b56574b 100644
--- a/addons/web/static/tests/fields/relational_fields_tests.js
+++ b/addons/web/static/tests/fields/relational_fields_tests.js
@@ -1,17 +1,11 @@
odoo.define('web.relational_fields_tests', function (require) {
"use strict";
-var AbstractField = require('web.AbstractField');
-var BasicModel = require('web.BasicModel');
var concurrency = require('web.concurrency');
var FormView = require('web.FormView');
-var ListRenderer = require('web.ListRenderer');
var ListView = require('web.ListView');
var relationalFields = require('web.relational_fields');
-var StandaloneFieldManagerMixin = require('web.StandaloneFieldManagerMixin');
var testUtils = require('web.test_utils');
-var Widget = require('web.Widget');
-var fieldUtils = require('web.field_utils');
var createView = testUtils.createView;
@@ -106,7 +100,6 @@ QUnit.module('relational_fields', {
turtle_foo: {string: "Foo", type: "char"},
turtle_bar: {string: "Bar", type: "boolean", default: true},
turtle_int: {string: "int", type: "integer", sortable: true},
- turtle_qux: {string: "Qux", type: "float", digits: [16,1], required: true, default: 1.5},
turtle_description: {string: "Description", type: "text"},
turtle_trululu: {string: "Trululu", type: "many2one", relation: 'partner'},
turtle_ref: {string: "Reference", type: 'reference', selection: [
@@ -134,7 +127,6 @@ QUnit.module('relational_fields', {
turtle_bar: false,
turtle_foo: "kawa",
turtle_int: 21,
- turtle_qux: 9.8,
partner_ids: [],
turtle_ref: 'product,37',
}],
@@ -158,11416 +150,6 @@ QUnit.module('relational_fields', {
}
}, function () {
- QUnit.module('FieldMany2One');
-
- QUnit.test('many2ones in form views', function (assert) {
- assert.expect(5);
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<sheet>' +
- '<group>' +
- '<field name="trululu" string="custom label"/>' +
- '</group>' +
- '</sheet>' +
- '</form>',
- archs: {
- 'partner,false,form': '<form string="Partners"><field name="display_name"/></form>',
- },
- res_id: 1,
- mockRPC: function (route, args) {
- if (args.method === 'get_formview_action') {
- assert.deepEqual(args.args[0], [4], "should call get_formview_action with correct id");
- return $.when({
- res_id: 17,
- type: 'ir.actions.act_window',
- target: 'current',
- res_model: 'res.partner'
- });
- }
- if (args.method === 'get_formview_id') {
- assert.deepEqual(args.args[0], [4], "should call get_formview_id with correct id");
- return $.when(false);
- }
- return this._super(route, args);
- },
- });
-
- testUtils.intercept(form, 'do_action', function (event) {
- assert.strictEqual(event.data.action.res_id, 17,
- "should do a do_action with correct parameters");
- });
-
- assert.strictEqual(form.$('a.o_form_uri:contains(aaa)').length, 1,
- "should contain a link");
- form.$('a.o_form_uri').click(); // click on the link in readonly mode (should trigger do_action)
-
- form.$buttons.find('.o_form_button_edit').click();
-
- form.$('.o_external_button').click(); // click on the external button (should do an RPC)
-
- assert.strictEqual($('.modal .modal-title').text().trim(), 'Open: custom label',
- "dialog title should display the custom string label");
-
- // TODO: test that we can edit the record in the dialog, and that the value is correctly
- // updated on close
- form.destroy();
- });
-
- QUnit.test('editing a many2one, but not changing anything', function (assert) {
- assert.expect(2);
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<sheet>' +
- '<field name="trululu"/>' +
- '</sheet>' +
- '</form>',
- archs: {
- 'partner,false,form': '<form string="Partners"><field name="display_name"/></form>',
- },
- res_id: 1,
- mockRPC: function (route, args) {
- if (args.method === 'get_formview_id') {
- assert.deepEqual(args.args[0], [4], "should call get_formview_id with correct id");
- return $.when(false);
- }
- return this._super(route, args);
- },
- viewOptions: {
- ids: [1, 2],
- },
- });
-
- form.$buttons.find('.o_form_button_edit').click();
-
- // click on the external button (should do an RPC)
- form.$('.o_external_button').click();
- // save and close modal
- $('.modal .modal-footer .btn-primary:first').click();
- // save form
- form.$buttons.find('.o_form_button_save').click();
- // click next on pager
- form.pager.$('.o_pager_next').click();
-
- // this checks that the view did not ask for confirmation that the
- // record is dirty
- assert.strictEqual(form.pager.$el.text().trim(), '2 / 2',
- 'pager should be at second page');
- form.destroy();
- });
-
- QUnit.test('context in many2one and default get', function (assert) {
- assert.expect(1);
-
- this.data.partner.fields.int_field.default = 14;
- this.data.partner.fields.trululu.default = 2;
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="int_field"/>' +
- '<field name="trululu" context="{\'blip\':int_field}"/>' +
- '</form>',
- mockRPC: function (route, args) {
- if (args.method === 'name_get') {
- assert.strictEqual(args.kwargs.context.blip, 14,
- 'context should have been properly sent to the nameget rpc');
- }
- return this._super(route, args);
- },
- });
- form.destroy();
- });
-
- QUnit.test('editing a many2one (with form view opened with external button)', function (assert) {
- assert.expect(1);
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<sheet>' +
- '<field name="trululu"/>' +
- '</sheet>' +
- '</form>',
- archs: {
- 'partner,false,form': '<form string="Partners"><field name="foo"/></form>',
- },
- res_id: 1,
- mockRPC: function (route, args) {
- if (args.method === 'get_formview_id') {
- return $.when(false);
- }
- return this._super(route, args);
- },
- viewOptions: {
- ids: [1, 2],
- },
- });
-
- form.$buttons.find('.o_form_button_edit').click();
-
- // click on the external button (should do an RPC)
- form.$('.o_external_button').click();
-
- $('.modal input[name="foo"]').val('brandon').trigger('input');
-
- // save and close modal
- $('.modal .modal-footer .btn-primary:first').click();
- // save form
- form.$buttons.find('.o_form_button_save').click();
- // click next on pager
- form.pager.$('.o_pager_next').click();
-
- // this checks that the view did not ask for confirmation that the
- // record is dirty
- assert.strictEqual(form.pager.$el.text().trim(), '2 / 2',
- 'pager should be at second page');
- form.destroy();
- });
-
- QUnit.test('many2ones in form views with show_adress', function (assert) {
- assert.expect(4);
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<sheet>' +
- '<group>' +
- '<field ' +
- 'name="trululu" ' +
- 'string="custom label" ' +
- 'context="{\'search_default_customer\':1, \'show_address\': 1}" ' +
- 'options="{\'always_reload\': True}"' +
- '/>' +
- '</group>' +
- '</sheet>' +
- '</form>',
- mockRPC: function (route, args) {
- if (args.method === 'name_get') {
- return this._super(route, args).then(function (result) {
- result[0][1] += '\nStreet\nCity ZIP';
- return result;
- });
- }
- return this._super(route, args);
- },
- res_id: 1,
- });
-
- assert.strictEqual($('a.o_form_uri').html(), 'aaa<br>Street<br>City ZIP',
- "input should have a multi-line content in readonly due to show_address");
- form.$buttons.find('.o_form_button_edit').click();
- assert.strictEqual(form.$('button.o_external_button:visible').length, 1,
- "should have an open record button");
-
- form.$('input.o_input').click();
-
- assert.strictEqual(form.$('button.o_external_button:visible').length, 1,
- "should still have an open record button");
- form.$('input.o_input').trigger('focusout');
- assert.strictEqual($('.modal button:contains(Create and edit)').length, 0,
- "there should not be a quick create modal");
-
- form.destroy();
- });
-
- QUnit.test('show_address works in a view embedded in a view of another type', function (assert) {
- assert.expect(1);
-
- this.data.turtle.records[1].turtle_trululu = 2;
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="display_name"/>' +
- '<field name="turtles"/>' +
- '</form>',
- res_id: 1,
- archs: {
- "turtle,false,form": '<form string="T">' +
- '<field name="display_name"/>' +
- '<field name="turtle_trululu" context="{\'show_address\': 1}" options="{\'always_reload\': True}"/>' +
- '</form>',
- "turtle,false,list": '<tree editable="bottom">' +
- '<field name="display_name"/>' +
- '</tree>',
- },
- mockRPC: function (route, args) {
- if (args.method === 'name_get') {
- return this._super(route, args).then(function (result) {
- if (args.model === 'partner' && args.kwargs.context.show_address) {
- result[0][1] += '\nrue morgue\nparis 75013';
- }
- return result;
- });
- }
- return this._super(route, args);
- },
- });
- // click the turtle field, opens a modal with the turtle form view
- form.$('.o_data_row:first td.o_data_cell').click();
-
- assert.strictEqual($('[name="turtle_trululu"]').text(), "second recordrue morgueparis 75013",
- "The partner's address should be displayed");
- form.destroy();
- });
-
- QUnit.test('many2one data is reloaded if there is a context to take into account', function (assert) {
- assert.expect(1);
-
- this.data.turtle.records[1].turtle_trululu = 2;
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="display_name"/>' +
- '<field name="turtles"/>' +
- '</form>',
- res_id: 1,
- archs: {
- "turtle,false,form": '<form string="T">' +
- '<field name="display_name"/>' +
- '<field name="turtle_trululu" context="{\'show_address\': 1}" options="{\'always_reload\': True}"/>' +
- '</form>',
- "turtle,false,list": '<tree editable="bottom">' +
- '<field name="display_name"/>' +
- '<field name="turtle_trululu"/>' +
- '</tree>',
- },
- mockRPC: function (route, args) {
- if (args.method === 'name_get') {
- return this._super(route, args).then(function (result) {
- if (args.model === 'partner' && args.kwargs.context.show_address) {
- result[0][1] += '\nrue morgue\nparis 75013';
- }
- return result;
- });
- }
- return this._super(route, args);
- },
- });
- // click the turtle field, opens a modal with the turtle form view
- form.$('.o_data_row:first').click();
-
- assert.strictEqual($('.modal [name=turtle_trululu]').text(), "second recordrue morgueparis 75013",
- "The partner's address should be displayed");
- form.destroy();
- });
-
- QUnit.test('many2ones in form views with search more', function (assert) {
- assert.expect(3);
- this.data.partner.records.push({
- id: 5,
- display_name: "Partner 4",
- }, {
- id: 6,
- display_name: "Partner 5",
- }, {
- id: 7,
- display_name: "Partner 6",
- }, {
- id: 8,
- display_name: "Partner 7",
- }, {
- id: 9,
- display_name: "Partner 8",
- }, {
- id: 10,
- display_name: "Partner 9",
- });
- this.data.partner.fields.datetime.searchable = true;
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<sheet>' +
- '<group>' +
- '<field name="trululu"/>' +
- '</group>' +
- '</sheet>' +
- '</form>',
- archs: {
- 'partner,false,list': '<tree><field name="display_name"/></tree>',
- 'partner,false,search': '<search><field name="datetime"/></search>',
- },
- res_id: 1,
- });
-
- form.$buttons.find('.o_form_button_edit').click();
- var $dropdown = form.$('.o_field_many2one input').autocomplete('widget');
- form.$('.o_field_many2one input').click();
- $dropdown.find('.o_m2o_dropdown_option:contains(Search)').mouseenter().click(); // Open Search More
-
- assert.strictEqual($('tr.o_data_row').length, 9, "should display 9 records");
-
- $('button:contains(Filters)').click();
- $('.o_add_custom_filter:visible').click(); // Add a custom filter, datetime field is selected
- assert.strictEqual($('.o_filter_condition select.o_searchview_extended_prop_field').val(), 'datetime',
- "datetime field should be selected");
- $('.o_apply_filter').click();
-
- assert.strictEqual($('tr.o_data_row').length, 0, "should display 0 records");
- form.destroy();
- });
-
- QUnit.test('onchanges on many2ones trigger when editing record in form view', function (assert) {
- assert.expect(10);
-
- this.data.partner.onchanges.user_id = function () {};
- this.data.user.fields.other_field = {string: "Other Field", type: "char"};
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<sheet>' +
- '<group>' +
- '<field name="user_id"/>' +
- '</group>' +
- '</sheet>' +
- '</form>',
- archs: {
- 'user,false,form': '<form string="Users"><field name="other_field"/></form>',
- },
- res_id: 1,
- mockRPC: function (route, args) {
- assert.step(args.method);
- if (args.method === 'get_formview_id') {
- return $.when(false);
- }
- if (args.method === 'onchange') {
- assert.strictEqual(args.args[1].user_id, 17,
- "onchange is triggered with correct user_id");
- }
- return this._super(route, args);
- },
- });
-
- // open the many2one in form view and change something
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_external_button').click();
- $('.modal-body input[name="other_field"]').val('wood').trigger('input');
-
- // save the modal and make sure an onchange is triggered
- $('.modal .modal-footer .btn-primary').first().click();
- assert.verifySteps(['read', 'get_formview_id', 'load_views', 'read', 'write', 'onchange', 'read']);
-
- // save the main record, and check that no extra rpcs are done (record
- // is not dirty, only a related record was modified)
- form.$buttons.find('.o_form_button_save').click();
- assert.verifySteps(['read', 'get_formview_id', 'load_views', 'read', 'write', 'onchange', 'read']);
- form.destroy();
- });
-
- QUnit.test('many2one fields with option "no_open"', function (assert) {
- assert.expect(3);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<sheet>' +
- '<group>' +
- '<field name="trululu" options="{&quot;no_open&quot;: True}" />' +
- '</group>' +
- '</sheet>' +
- '</form>',
- res_id: 1,
- });
-
- assert.strictEqual(form.$('span.o_field_widget[name=trululu]').length, 1,
- "should be displayed inside a span (sanity check)");
- assert.strictEqual(form.$('span.o_form_uri').length, 0, "should not have an anchor");
-
- form.$buttons.find('.o_form_button_edit').click();
- assert.strictEqual(form.$('.o_field_widget[name=trululu] .o_external_button').length,
- 0, "should not have the button to open the record");
-
- form.destroy();
- });
-
- QUnit.test('many2one in edit mode', function (assert) {
- assert.expect(16);
-
- // create 10 partners to have the 'Search More' option in the autocomplete dropdown
- for (var i=0; i<10; i++) {
- var id = 20 + i;
- this.data.partner.records.push({id: id, display_name: "Partner " + id});
- }
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<sheet>' +
- '<group>' +
- '<field name="trululu"/>' +
- '</group>' +
- '</sheet>' +
- '</form>',
- res_id: 1,
- archs: {
- 'partner,false,list': '<tree string="Partners"><field name="display_name"/></tree>',
- 'partner,false,search': '<search string="Partners">' +
- '<field name="display_name" string="Name"/>' +
- '</search>',
- },
- mockRPC: function (route, args) {
- if (route === '/web/dataset/call_kw/partner/write') {
- assert.strictEqual(args.args[1].trululu, 20, "should write the correct id");
- }
- return this._super.apply(this, arguments);
- },
- });
-
- // the SelectCreateDialog requests the session, so intercept its custom
- // event to specify a fake session to prevent it from crashing
- testUtils.intercept(form, 'get_session', function (event) {
- event.data.callback({user_context: {}});
- });
-
- form.$buttons.find('.o_form_button_edit').click();
- var $dropdown = form.$('.o_field_many2one input').autocomplete('widget');
-
- form.$('.o_field_many2one input').click();
- assert.ok($dropdown.is(':visible'),
- 'clicking on the m2o input should open the dropdown if it is not open yet');
- assert.strictEqual($dropdown.find('li:not(.o_m2o_dropdown_option)').length, 7,
- 'autocomplete should contains 7 suggestions');
- assert.strictEqual($dropdown.find('li.o_m2o_dropdown_option').length, 2,
- 'autocomplete should contain "Search More" and Create and Edit..."');
-
- form.$('.o_field_many2one input').click();
- assert.ok(!$dropdown.is(':visible'),
- 'clicking on the m2o input should close the dropdown if it is open');
-
- // change the value of the m2o with a suggestion of the dropdown
- form.$('.o_field_many2one input').click();
- $dropdown.find('li:first()').click();
- assert.ok(!$dropdown.is(':visible'), 'clicking on a value should close the dropdown');
- assert.strictEqual(form.$('.o_field_many2one input').val(), 'first record',
- 'value of the m2o should have been correctly updated');
-
- // change the value of the m2o with a record in the 'Search More' modal
- form.$('.o_field_many2one input').click();
- // click on 'Search More' (mouseenter required by ui-autocomplete)
- $dropdown.find('.o_m2o_dropdown_option:contains(Search)').mouseenter().click();
- assert.ok($('.modal .o_list_view').length, "should have opened a list view in a modal");
- assert.ok(!$('.modal .o_list_view .o_list_record_selector').length,
- "there should be no record selector in the list view");
- assert.ok(!$('.modal .modal-footer .o_select_button').length,
- "there should be no 'Select' button in the footer");
- assert.ok($('.modal tbody tr').length > 10, "list should contain more than 10 records");
- // filter the list using the searchview
- $('.modal .o_searchview_input').trigger({type: 'keypress', which: 80}); // P
- $('.modal .o_searchview_input').trigger({type: 'keydown', which: 13}); // enter
- assert.strictEqual($('.modal tbody tr').length, 10,
- "list should be restricted to records containing a P (10 records)");
- // choose a record
- $('.modal tbody tr:contains(Partner 20)').click(); // choose record 'Partner 20'
- assert.ok(!$('.modal').length, "should have closed the modal");
- assert.ok(!$dropdown.is(':visible'), 'should have closed the dropdown');
- assert.strictEqual(form.$('.o_field_many2one input').val(), 'Partner 20',
- 'value of the m2o should have been correctly updated');
-
- // save
- form.$buttons.find('.o_form_button_save').click();
- assert.strictEqual(form.$('a.o_form_uri').text(), 'Partner 20',
- "should display correct value after save");
-
- form.destroy();
- });
-
- QUnit.test('many2one in non edit mode', function (assert) {
- assert.expect(3);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="trululu"/>' +
- '</form>',
- res_id: 1,
- });
-
- assert.strictEqual(form.$('a.o_form_uri').length, 1,
- "should display 1 m2o link in form");
- assert.strictEqual(form.$('a.o_form_uri').attr('href'), "#id=4&model=partner",
- "href should contain id and model");
-
- // Remove value from many2one and then save, there should not have href with id and model on m2o anchor
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_field_many2one input').val('').trigger('keyup').trigger('focusout');
- form.$buttons.find('.o_form_button_save').click();
-
- assert.strictEqual(form.$('a.o_form_uri').attr('href'), "#",
- "href should have #");
-
- form.destroy();
- });
-
- QUnit.test('many2one searches with correct value', function (assert) {
- var done = assert.async();
- assert.expect(6);
-
- var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
- relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<sheet>' +
- '<field name="trululu"/>' +
- '</sheet>' +
- '</form>',
- res_id: 1,
- mockRPC: function (route, args) {
- if (args.method === 'name_search') {
- assert.step('search: ' + args.kwargs.name);
- }
- return this._super.apply(this, arguments);
- },
- viewOptions: {
- mode: 'edit',
- },
- });
-
- assert.strictEqual(form.$('.o_field_many2one input').val(), 'aaa',
- "should be initially set to 'aaa'");
-
- form.$('.o_field_many2one input').click(); // should search with ''
- // unset the many2one -> should search again with ''
- form.$('.o_field_many2one input').val('').trigger('keydown');
- concurrency.delay(0).then(function () {
- // write 'p' -> should search with 'p'
- form.$('.o_field_many2one input').val('p').trigger('keydown').trigger('keyup');
-
- return concurrency.delay(0);
- }).then(function () {
- // close and re-open the dropdown -> should search with 'p' again
- form.$('.o_field_many2one input').click();
- form.$('.o_field_many2one input').click();
-
- assert.verifySteps(['search: ', 'search: ', 'search: p', 'search: p']);
-
- relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
- form.destroy();
- done();
- });
- });
-
- QUnit.test('many2one field with option always_reload', function (assert) {
- assert.expect(4);
- var count = 0;
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form>' +
- '<field name="trululu" options="{\'always_reload\': True}"/>' +
- '</form>',
- res_id: 2,
- mockRPC: function (route, args) {
- if (args.method === 'name_get') {
- count++;
- return $.when([[1, "first record\nand some address"]]);
- }
- return this._super(route, args);
- },
- });
-
- assert.strictEqual(count, 1, "an extra name_get should have been done");
- assert.ok(form.$('a:contains(and some address)').length,
- "should display additional result");
-
- form.$buttons.find('.o_form_button_edit').click();
-
- assert.strictEqual(form.$('input').val(), "first record",
- "actual field value should be displayed to be edited");
-
- form.$buttons.find('.o_form_button_save').click();
-
- assert.ok(form.$('a:contains(and some address)').length,
- "should still display additional result");
- form.destroy();
- });
-
- QUnit.test('many2one field and list navigation', function (assert) {
- assert.expect(3);
-
- var list = createView({
- View: ListView,
- model: 'partner',
- data: this.data,
- arch: '<tree editable="bottom"><field name="trululu"/></tree>',
- });
-
- // edit first input, to trigger autocomplete
- list.$('.o_data_row .o_data_cell').first().click();
- list.$('.o_data_cell input').val('').trigger('input');
-
- // press keydown, to select first choice
- list.$('.o_data_cell input').focus().trigger($.Event('keydown', {
- which: $.ui.keyCode.DOWN,
- keyCode: $.ui.keyCode.DOWN,
- }));
-
- // we now check that the dropdown is open (and that the focus did not go
- // to the next line)
- var $dropdown = list.$('.o_field_many2one input').autocomplete('widget');
- assert.ok($dropdown.is(':visible'), "dropdown should be visible");
- assert.ok(list.$('.o_data_row:eq(0)').hasClass('o_selected_row'),
- 'first data row should still be selected');
- assert.ok(!list.$('.o_data_row:eq(1)').hasClass('o_selected_row'),
- 'second data row should not be selected');
-
- list.destroy();
- });
-
- QUnit.test('standalone many2one field', function (assert) {
- assert.expect(4);
- var done = assert.async();
-
- var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
- relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
-
- var fixture = $('#qunit-fixture');
- var self = this;
-
- var model = testUtils.createModel({
- Model: BasicModel,
- data: this.data,
- });
-
- model.makeRecord('coucou', [{
- name: 'partner_id',
- relation: 'partner',
- type: 'many2one',
- value: [1, 'first partner'],
- }]).then(function (recordID) {
- var record = model.get(recordID);
- // create a new widget that uses the StandaloneFieldManagerMixin
- var StandaloneWidget = Widget.extend(StandaloneFieldManagerMixin, {
- init: function (parent) {
- this._super.apply(this, arguments);
- StandaloneFieldManagerMixin.init.call(this, parent);
- },
- });
- var parent = new StandaloneWidget(model);
- testUtils.addMockEnvironment(parent, {
- data: self.data,
- mockRPC: function (route, args) {
- assert.step(args.method);
- return this._super.apply(this, arguments);
- },
- });
-
- var relField = new relationalFields.FieldMany2One(parent, 'partner_id', record, {
- mode: 'edit',
- noOpen: true,
- });
-
- relField.appendTo(fixture);
- $('input.o_input').val('xyzzrot').trigger('input');
-
- concurrency.delay(0).then(function () {
- var $dropdown = $('input.o_input').autocomplete('widget');
- $dropdown.find('.o_m2o_dropdown_option:contains(Create)')
- .first().mouseenter().click();
-
- assert.strictEqual(relField.$('.o_external_button').length, 0,
- "should not have the button to open the record");
- assert.verifySteps(['name_search', 'name_create']);
-
- parent.destroy();
- model.destroy();
- relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
- done();
- });
- });
- });
-
- // QUnit.test('onchange on a many2one to a different model', function (assert) {
- // This test is commented because the mock server does not give the correct response.
- // It should return a couple [id, display_name], but I don't know the logic used
- // by the server, so it's hard to emulate it correctly
- // assert.expect(2);
-
- // this.data.partner.records[0].product_id = 41;
- // this.data.partner.onchanges = {
- // foo: function(obj) {
- // obj.product_id = 37;
- // },
- // };
-
- // var form = createView({
- // View: FormView,
- // model: 'partner',
- // data: this.data,
- // arch: '<form>' +
- // '<field name="foo"/>' +
- // '<field name="product_id"/>' +
- // '</form>',
- // res_id: 1,
- // });
- // form.$buttons.find('.o_form_button_edit').click();
- // assert.strictEqual(form.$('input').eq(1).val(), 'xpad', "initial product_id val should be xpad");
-
- // form.$('input').eq(0).val("let us trigger an onchange").trigger('input');
-
- // assert.strictEqual(form.$('input').eq(1).val(), 'xphone', "onchange should have been applied");
- // });
-
- QUnit.test('form: quick create then save directly', function (assert) {
- var done = assert.async();
- assert.expect(5);
-
- var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
- relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
-
- var def = $.Deferred();
- var newRecordID;
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form>' +
- '<field name="trululu"/>' +
- '</form>',
- mockRPC: function (route, args) {
- var result = this._super.apply(this, arguments);
- if (args.method === 'name_create') {
- assert.step('name_create');
- return def.then(_.constant(result)).then(function (nameGet) {
- newRecordID = nameGet[0];
- return nameGet;
- });
- }
- if (args.method === 'create') {
- assert.step('create');
- assert.strictEqual(args.args[0].trululu, newRecordID,
- "should create with the correct m2o id");
- }
- return result;
- },
- });
-
- var $dropdown = form.$('.o_field_many2one input').autocomplete('widget');
- form.$('.o_field_many2one input').val('b').trigger('keydown');
- concurrency.delay(0).then(function () {
- $dropdown.find('li:first()').click(); // quick create 'b'
- form.$buttons.find('.o_form_button_save').click();
-
- assert.verifySteps(['name_create'],
- "should wait for the name_create before creating the record");
-
- def.resolve();
-
- assert.verifySteps(['name_create', 'create']);
-
- relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
- form.destroy();
- done();
- });
- });
-
- QUnit.test('list: quick create then save directly', function (assert) {
- var done = assert.async();
- assert.expect(8);
-
- var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
- relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
-
- var def = $.Deferred();
- var newRecordID;
- var list = createView({
- View: ListView,
- model: 'partner',
- data: this.data,
- arch: '<tree editable="top">' +
- '<field name="trululu"/>' +
- '</tree>',
- mockRPC: function (route, args) {
- var result = this._super.apply(this, arguments);
- if (args.method === 'name_create') {
- assert.step('name_create');
- return def.then(_.constant(result)).then(function (nameGet) {
- newRecordID = nameGet[0];
- return nameGet;
- });
- }
- if (args.method === 'create') {
- assert.step('create');
- assert.strictEqual(args.args[0].trululu, newRecordID,
- "should create with the correct m2o id");
- }
- return result;
- },
- });
-
- list.$buttons.find('.o_list_button_add').click();
-
- var $dropdown = list.$('.o_field_many2one input').autocomplete('widget');
- list.$('.o_field_many2one input').val('b').trigger('keydown');
- concurrency.delay(0).then(function () {
- $dropdown.find('li:first()').click(); // quick create 'b'
- list.$buttons.find('.o_list_button_add').click();
-
- assert.verifySteps(['name_create'],
- "should wait for the name_create before creating the record");
- assert.strictEqual(list.$('.o_data_row').length, 4,
- "should wait for the name_create before adding the new row");
-
- def.resolve();
-
- assert.verifySteps(['name_create', 'create']);
- assert.strictEqual(list.$('.o_data_row:nth(1) .o_data_cell').text(), 'b',
- "created row should have the correct m2o value");
- assert.strictEqual(list.$('.o_data_row').length, 5,
- "should have added the fifth row");
-
- relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
- list.destroy();
- done();
- });
- });
-
- QUnit.test('list in form: quick create then save directly', function (assert) {
- var done = assert.async();
- assert.expect(6);
-
- var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
- relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
-
- var def = $.Deferred();
- var newRecordID;
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form>' +
- '<sheet>' +
- '<field name="p">' +
- '<tree editable="bottom">' +
- '<field name="trululu"/>' +
- '</tree>' +
- '</field>' +
- '</sheet>' +
- '</form>',
- mockRPC: function (route, args) {
- var result = this._super.apply(this, arguments);
- if (args.method === 'name_create') {
- assert.step('name_create');
- return def.then(_.constant(result)).then(function (nameGet) {
- newRecordID = nameGet[0];
- return nameGet;
- });
- }
- if (args.method === 'create') {
- assert.step('create');
- assert.strictEqual(args.args[0].p[0][2].trululu, newRecordID,
- "should create with the correct m2o id");
- }
- return result;
- },
- });
-
- form.$('.o_field_x2many_list_row_add a').click();
-
- var $dropdown = form.$('.o_field_many2one input').autocomplete('widget');
- form.$('.o_field_many2one input').val('b').trigger('keydown');
- concurrency.delay(0).then(function () {
- $dropdown.find('li:first()').click(); // quick create 'b'
- form.$buttons.find('.o_form_button_save').click();
-
- assert.verifySteps(['name_create'],
- "should wait for the name_create before creating the record");
-
- def.resolve();
-
- assert.verifySteps(['name_create', 'create']);
- assert.strictEqual(form.$('.o_data_row:first .o_data_cell').text(), 'b',
- "first row should have the correct m2o value");
-
- relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
- form.destroy();
- done();
- });
- });
-
- QUnit.test('list in form: quick create then add a new line directly', function (assert) {
- // required many2one inside a one2many list: directly after quick creating
- // a new many2one value (before the name_create returns), click on add an item:
- // at this moment, the many2one has still no value, and as it is required,
- // the row is discarded if a saveLine is requested. However, it should
- // wait for the name_create to return before trying to save the line.
- var done = assert.async();
- assert.expect(8);
-
- this.data.partner.onchanges = {
- trululu: function () {},
- };
-
- var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
- relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
-
- var def = $.Deferred();
- var newRecordID;
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form>' +
- '<sheet>' +
- '<field name="p">' +
- '<tree editable="bottom">' +
- '<field name="trululu" required="1"/>' +
- '</tree>' +
- '</field>' +
- '</sheet>' +
- '</form>',
- mockRPC: function (route, args) {
- var result = this._super.apply(this, arguments);
- if (args.method === 'name_create') {
- return def.then(_.constant(result)).then(function (nameGet) {
- newRecordID = nameGet[0];
- return nameGet;
- });
- }
- if (args.method === 'create') {
- assert.deepEqual(args.args[0].p[0][2].trululu, newRecordID);
- }
- return result;
- },
- });
-
- form.$('.o_field_x2many_list_row_add a').click();
-
- var $dropdown = form.$('.o_field_many2one input').autocomplete('widget');
- form.$('.o_field_many2one input').val('b').trigger('keydown');
- concurrency.delay(0).then(function () {
- $dropdown.find('li:first()').click(); // quick create 'b'
- form.$('.o_field_x2many_list_row_add a').click();
-
- assert.strictEqual(form.$('.o_data_row').length, 1,
- "there should still be only one row");
- assert.ok(form.$('.o_data_row').hasClass('o_selected_row'),
- "the row should still be in edition");
-
- def.resolve();
-
- assert.strictEqual(form.$('.o_data_row:first .o_data_cell').text(), 'b',
- "first row should have the correct m2o value");
- assert.strictEqual(form.$('.o_data_row').length, 2,
- "there should now be 2 rows");
- assert.ok(form.$('.o_data_row:nth(1)').hasClass('o_selected_row'),
- "the second row should be in edition");
-
- form.$buttons.find('.o_form_button_save').click();
-
- assert.strictEqual(form.$('.o_data_row').length, 1,
- "there should be 1 row saved (the second one was empty and invalid)");
- assert.strictEqual(form.$('.o_data_row .o_data_cell').text(), 'b',
- "should have the correct m2o value");
-
- relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
- form.destroy();
- done();
- });
- });
-
- QUnit.test('list in form: create with one2many with many2one', function (assert) {
- assert.expect(1);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form>' +
- '<sheet>' +
- '<field name="p">' +
- '<tree editable="bottom">' +
- '<field name="display_name"/>' +
- '<field name="trululu"/>' +
- '</tree>' +
- '</field>' +
- '</sheet>' +
- '</form>',
- mockRPC: function (route, args) {
- if (args.method === 'default_get') {
- return $.when({p: [[0, 0, {display_name: 'new record'}]]});
- } else if (args.method === 'name_get') {
- // This should not be called at all and thus is not accounted for
- // in the assert.expect. If this is called, you broke this test.
- assert.notOk(_.str.startsWith(args.args[0][0], 'virtual_'),
- "should not call name_get for the m2o inside o2m which has no value");
- }
- return this._super.apply(this, arguments);
- },
- });
-
- assert.strictEqual($('td.o_data_cell:first').text(), 'new record',
- "should have created the new record in the o2m with the correct name");
-
- form.destroy();
- });
-
- QUnit.test('list in form: create with one2many with many2one (version 2)', function (assert) {
- // This test simulates the exact same scenario as the previous one,
- // except that the value for the many2one is explicitely set to false,
- // which is stupid, but this happens, so we have to handle it
- assert.expect(1);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form>' +
- '<sheet>' +
- '<field name="p">' +
- '<tree editable="bottom">' +
- '<field name="display_name"/>' +
- '<field name="trululu"/>' +
- '</tree>' +
- '</field>' +
- '</sheet>' +
- '</form>',
- mockRPC: function (route, args) {
- if (args.method === 'default_get') {
- return $.when({p: [[0, 0, {display_name: 'new record', trululu: false}]]});
- } else if (args.method === 'name_get') {
- // This should not be called at all and thus is not accounted for
- // in the assert.expect. If this is called, you broke this test.
- assert.notOk(_.str.startsWith(args.args[0][0], 'virtual_'),
- "should not call name_get for the m2o inside o2m which has no value");
- }
- return this._super.apply(this, arguments);
- },
- });
-
- assert.strictEqual($('td.o_data_cell:first').text(), 'new record',
- "should have created the new record in the o2m with the correct name");
-
- form.destroy();
- });
-
- QUnit.test('item not dropped on discard with empty required field (default_get)', function (assert) {
- // This test simulates discarding a record that has been created with
- // one of its required field that is empty. When we discard the changes
- // on this empty field, it should not assume that this record should be
- // abandonned, since it has been added (even though it is a new record).
- assert.expect(8);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form>' +
- '<sheet>' +
- '<field name="p">' +
- '<tree editable="bottom">' +
- '<field name="display_name"/>' +
- '<field name="trululu" required="1"/>' +
- '</tree>' +
- '</field>' +
- '</sheet>' +
- '</form>',
- mockRPC: function (route, args) {
- if (args.method === 'default_get') {
- return $.when({ p: [[0, 0, { display_name: 'new record', trululu: false }]] });
- }
- return this._super.apply(this, arguments);
- },
- });
-
- assert.strictEqual($('tr.o_data_row').length, 1,
- "should have created the new record in the o2m");
- assert.strictEqual($('td.o_data_cell').first().text(), "new record",
- "should have the correct displayed name");
-
- var requiredElement = $('td.o_data_cell.o_required_modifier');
- assert.strictEqual(requiredElement.length, 1,
- "should have a required field on this record");
- assert.strictEqual(requiredElement.text(), "",
- "should have empty string in the required field on this record");
-
- requiredElement.click();
- // discard by clicking on body
- $('body').click();
-
- assert.strictEqual($('tr.o_data_row').length, 1,
- "should still have the record in the o2m");
- assert.strictEqual($('td.o_data_cell').first().text(), "new record",
- "should still have the correct displayed name");
-
- // update selector of required field element
- requiredElement = $('td.o_data_cell.o_required_modifier');
- assert.strictEqual(requiredElement.length, 1,
- "should still have the required field on this record");
- assert.strictEqual(requiredElement.text(), "",
- "should still have empty string in the required field on this record");
- form.destroy();
- });
-
- QUnit.test('list in form: name_get with unique ids (default_get)', function (assert) {
- assert.expect(2);
-
- this.data.partner.records[0].display_name = "MyTrululu";
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form>' +
- '<sheet>' +
- '<field name="p">' +
- '<tree editable="bottom">' +
- '<field name="trululu"/>' +
- '</tree>' +
- '</field>' +
- '</sheet>' +
- '</form>',
- mockRPC: function (route, args) {
- if (args.method === 'default_get') {
- return $.when({
- p: [
- [0, 0, { trululu: 1 }],
- [0, 0, { trululu: 1 }]
- ]
- });
- }
- if (args.method === 'name_get') {
- assert.deepEqual(args.args[0], _.uniq(args.args[0]),
- "should not have duplicates in name_get rpc");
- }
- return this._super.apply(this, arguments);
- },
- });
-
- assert.strictEqual(form.$('td.o_data_cell').text(), "MyTrululuMyTrululu",
- "both records should have the correct display_name for trululu field");
-
- form.destroy();
- });
-
- QUnit.test('list in form: show name of many2one fields in multi-page (default_get)', function (assert) {
- assert.expect(4);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form>' +
- '<sheet>' +
- '<field name="p">' +
- '<tree editable="bottom" limit="1">' +
- '<field name="display_name"/>' +
- '<field name="trululu"/>' +
- '</tree>' +
- '</field>' +
- '</sheet>' +
- '</form>',
- mockRPC: function (route, args) {
- if (args.method === 'default_get') {
- return $.when({
- p: [
- [0, 0, { display_name: 'record1', trululu: 1 }],
- [0, 0, { display_name: 'record2', trululu: 2 }]
- ]
- });
- }
- return this._super.apply(this, arguments);
- },
- });
-
- assert.strictEqual(form.$('td.o_data_cell').first().text(),
- "record1", "should show display_name of 1st record");
- assert.strictEqual(form.$('td.o_data_cell').first().next().text(),
- "first record", "should show display_name of trululu of 1st record");
-
- form.$('button.o_pager_next').click();
-
- assert.strictEqual(form.$('td.o_data_cell').first().text(),
- "record2", "should show display_name of 2nd record");
- assert.strictEqual(form.$('td.o_data_cell').first().next().text(),
- "second record", "should show display_name of trululu of 2nd record");
-
- form.destroy();
- });
-
- QUnit.test('list in form: item not dropped on discard with empty required field (onchange in default_get)', function (assert) {
- // variant of the test "list in form: discard newly added element with
- // empty required field (default_get)", in which the `default_get`
- // performs an `onchange` at the same time. This `onchange` may create
- // some records, which should not be abandoned on discard, similarly
- // to records created directly by `default_get`
- assert.expect(7);
-
- var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
- relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
-
- this.data.partner.onchanges = {
- product_id: function (obj) {
- if (obj.product_id === 37) {
- obj.p = [[0, 0, { display_name: "entry", trululu: false }]];
- }
- },
- };
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form>' +
- '<field name="product_id"/>' +
- '<field name="p">' +
- '<tree editable="bottom">' +
- '<field name="display_name"/>' +
- '<field name="trululu" required="1"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- mockRPC: function (route, args) {
- if (args.method === 'default_get') {
- return $.when({
- product_id: 37,
- });
- }
- return this._super.apply(this, arguments);
- },
- });
-
- // check that there is a record in the editable list with empty string as required field
- assert.strictEqual(form.$('.o_data_row').length, 1,
- "should have a row in the editable list");
- assert.strictEqual($('td.o_data_cell').first().text(), "entry",
- "should have the correct displayed name");
- var requiredField = $('td.o_data_cell.o_required_modifier');
- assert.strictEqual(requiredField.length, 1,
- "should have a required field on this record");
- assert.strictEqual(requiredField.text(), "",
- "should have empty string in the required field on this record");
-
- // click on empty required field in editable list record
- requiredField.click();
- // click off so that the required field still stay empty
- $('body').click();
-
- // record should not be dropped
- assert.strictEqual(form.$('.o_data_row').length, 1,
- "should not have dropped record in the editable list");
- assert.strictEqual($('td.o_data_cell').first().text(), "entry",
- "should still have the correct displayed name");
- assert.strictEqual($('td.o_data_cell.o_required_modifier').text(), "",
- "should still have empty string in the required field");
-
- relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
- form.destroy();
- });
-
- QUnit.test('list in form: item not dropped on discard with empty required field (onchange on list after default_get)', function (assert) {
- // discarding a record from an `onchange` in a `default_get` should not
- // abandon the record. This should not be the case for following
- // `onchange`, except if an onchange make some changes on the list:
- // in particular, if an onchange make changes on the list such that
- // a record is added, this record should not be dropped on discard
- assert.expect(8);
-
- var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
- relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
-
- this.data.partner.onchanges = {
- product_id: function (obj) {
- if (obj.product_id === 37) {
- obj.p = [[0, 0, { display_name: "entry", trululu: false }]];
- }
- },
- };
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form>' +
- '<field name="product_id"/>' +
- '<field name="p">' +
- '<tree editable="bottom">' +
- '<field name="display_name"/>' +
- '<field name="trululu" required="1"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- });
-
- // check no record in list
- assert.strictEqual(form.$('.o_data_row').length, 0,
- "should have no row in the editable list");
-
- // select product_id to force on_change in editable list
- form.$('.o_field_widget[name="product_id"] .o_input').click();
- $('.ui-menu-item').first().click();
-
- // check that there is a record in the editable list with empty string as required field
- assert.strictEqual(form.$('.o_data_row').length, 1,
- "should have a row in the editable list");
- assert.strictEqual($('td.o_data_cell').first().text(), "entry",
- "should have the correct displayed name");
- var requiredField = $('td.o_data_cell.o_required_modifier');
- assert.strictEqual(requiredField.length, 1,
- "should have a required field on this record");
- assert.strictEqual(requiredField.text(), "",
- "should have empty string in the required field on this record");
-
- // click on empty required field in editable list record
- requiredField.click();
- // click off so that the required field still stay empty
- $('body').click();
-
- // record should not be dropped
- assert.strictEqual(form.$('.o_data_row').length, 1,
- "should not have dropped record in the editable list");
- assert.strictEqual($('td.o_data_cell').first().text(), "entry",
- "should still have the correct displayed name");
- assert.strictEqual($('td.o_data_cell.o_required_modifier').text(), "",
- "should still have empty string in the required field");
-
- relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
- form.destroy();
- });
-
- QUnit.test('item dropped on discard with empty required field with "Add an item" (invalid on "ADD")', function (assert) {
- // when a record in a list is added with "Add an item", it should
- // always be dropped on discard if some required field are empty
- // at the record creation.
- assert.expect(6);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form>' +
- '<field name="p">' +
- '<tree editable="bottom">' +
- '<field name="display_name"/>' +
- '<field name="trululu" required="1"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- });
-
- // Click on "Add an item"
- form.$('.o_field_x2many_list_row_add a').click();
- var charField = form.$('.o_field_widget.o_field_char[name="display_name"]');
- var requiredField = form.$('.o_field_widget.o_required_modifier[name="trululu"]');
- charField.val("some text");
- assert.strictEqual(charField.length, 1,
- "should have a char field 'display_name' on this record");
- assert.notOk(charField.hasClass('o_required_modifier'),
- "the char field should not be required on this record");
- assert.strictEqual(charField.val(), "some text",
- "should have entered text in the char field on this record");
- assert.strictEqual(requiredField.length, 1,
- "should have a required field 'trululu' on this record");
- assert.strictEqual(requiredField.val().trim(), "",
- "should have empty string in the required field on this record");
-
- // click on empty required field in editable list record
- requiredField.click();
- // click off so that the required field still stay empty
- $('body').click();
-
- // record should be dropped
- assert.strictEqual(form.$('.o_data_row').length, 0,
- "should have dropped record in the editable list");
-
- form.destroy();
- });
-
- QUnit.test('item not dropped on discard with empty required field with "Add an item" (invalid on "UPDATE")', function (assert) {
- // when a record in a list is added with "Add an item", it should
- // be temporarily added to the list when it is valid (e.g. required
- // fields are non-empty). If the record is updated so that the required
- // field is empty, and it is discarded, then the record should not be
- // dropped.
- assert.expect(8);
-
- var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
- relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form>' +
- '<field name="p">' +
- '<tree editable="bottom">' +
- '<field name="display_name"/>' +
- '<field name="trululu" required="1"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- });
-
- assert.strictEqual(form.$('.o_data_row').length, 0,
- "should initially not have any record in the list");
-
- // Click on "Add an item"
- form.$('.o_field_x2many_list_row_add a').click();
- assert.strictEqual(form.$('.o_data_row').length, 1,
- "should have a temporary record in the list");
-
- var $inputEditMode = form.$('.o_field_widget.o_required_modifier[name="trululu"] input');
- assert.strictEqual($inputEditMode.length, 1,
- "should have a required field 'trululu' on this record");
- assert.strictEqual($inputEditMode.val(), "",
- "should have empty string in the required field on this record");
-
- // add something to required field and leave edit mode of the record
- $inputEditMode.click();
- $('li.ui-menu-item').first().click();
- $('body').click(); // leave edit mode on the line
-
- var $inputReadonlyMode = form.$('.o_data_cell.o_required_modifier');
- assert.strictEqual(form.$('.o_data_row').length, 1,
- "should not have dropped valid record when leaving edit mode");
- assert.strictEqual($inputReadonlyMode.text(), "first record",
- "should have put some content in the required field on this record");
-
- // remove the required field and leave edit mode of the record
- $('.o_data_row').click(); // enter edit mode on the line
- $inputEditMode.click();
- $inputEditMode.val("");
- $('body').click();
-
- assert.strictEqual(form.$('.o_data_row').length, 1,
- "should not have dropped record in the list on discard (invalid on UPDATE)");
- assert.strictEqual($inputReadonlyMode.text(), "first record",
- "should keep previous valid required field content on this record");
-
- relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
- form.destroy();
- });
-
- QUnit.test('list in form: default_get with x2many create', function (assert) {
- assert.expect(5);
-
- var displayName = 'brandon is the new timmy';
- this.data.partner.onchanges.timmy = function (obj) {
- assert.deepEqual(
- obj.timmy,
- [
- [6, false, []],
- [0, obj.timmy[1][1], {display_name: displayName, name: 'brandon'}]
- ],
- "should have properly created the x2many command list");
- obj.int_field = obj.timmy.length;
- };
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form>' +
- '<sheet>' +
- '<field name="timmy">' +
- '<tree editable="bottom">' +
- '<field name="display_name"/>' +
- '</tree>' +
- '</field>' +
- '<field name="int_field"/>' +
- '</sheet>' +
- '</form>',
- mockRPC: function (route, args) {
- if (args.method === 'default_get') {
- return $.when({timmy: [[0, 0, {display_name: 'brandon is the new timmy', name: 'brandon'}]]});
- }
- if (args.method === 'create') {
- assert.deepEqual(args.args[0], {
- int_field: 2,
- timmy: [
- [6, false, []],
- [0, args.args[0].timmy[1][1], {display_name: displayName, name: 'brandon'}],
- ],
- }, "should send the correct values to create");
- }
- return this._super.apply(this, arguments);
- },
- });
-
- assert.strictEqual($('td.o_data_cell:first').text(), 'brandon is the new timmy',
- "should have created the new record in the m2m with the correct name");
- assert.strictEqual($('input.o_field_integer').val(), '2',
- "should have called and executed the onchange properly");
-
- // edit the subrecord and save
- displayName = 'new value';
- form.$('.o_data_cell').click();
- form.$('.o_data_cell input').val(displayName).trigger('input');
- form.$buttons.find('.o_form_button_save').click();
-
- form.destroy();
- });
-
- QUnit.test('list in form: default_get with x2many create and onchange', function (assert) {
- assert.expect(2);
-
- this.data.partner.onchanges.turtles = function (obj) {
- assert.deepEqual(
- obj.turtles,
- [
- [4, 2, false],
- [1, 2, {turtle_foo: 'blip'}],
- [4, 3, false],
- [1, 3, {turtle_foo: 'kawa'}]
- ],
- "should have properly created the x2many command list");
- };
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form>' +
- '<sheet>' +
- '<field name="turtles">' +
- '<tree editable="bottom">' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '</field>' +
- '<field name="int_field"/>' +
- '</sheet>' +
- '</form>',
- mockRPC: function (route, args) {
- if (args.method === 'default_get') {
- return $.when({turtles: [[6, 0, [2,3]]]});
- }
- if (args.method === 'create') {
- // it would be even better if we did not send the current
- // unchanged state with the command 1, but this seems more
- // difficult.
- assert.deepEqual(args.args[0].turtles, [
- [4, 2, false],
- [1, 2, {turtle_foo: 'blip'}],
- [4, 3, false],
- [1, 3, {turtle_foo: 'kawa'}]
- ], 'should send proper commands to create method');
- }
- return this._super.apply(this, arguments);
- },
- });
-
- form.$buttons.find('.o_form_button_save').click();
-
- form.destroy();
- });
-
- QUnit.test('list in form: call button in sub view', function (assert) {
- assert.expect(6);
-
- this.data.partner.records[0].p = [2];
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form>' +
- '<sheet>' +
- '<field name="p">' +
- '<tree editable="bottom">' +
- '<field name="product_id"/>' +
- '</tree>' +
- '</field>' +
- '</sheet>' +
- '</form>',
- res_id: 1,
- mockRPC: function (route, args) {
- if (route === '/web/dataset/call_kw/product/get_formview_id') {
- return $.when(false);
- }
- return this._super.apply(this, arguments);
- },
- intercepts: {
- execute_action: function (event) {
- assert.strictEqual(event.data.env.model, 'product',
- 'should call with correct model in env');
- assert.strictEqual(event.data.env.currentID, 37,
- 'should call with correct currentID in env');
- assert.deepEqual(event.data.env.resIDs, [37],
- 'should call with correct resIDs in env');
- },
- },
- archs: {
- 'product,false,form': '<form string="Partners">' +
- '<header>' +
- '<button name="action" type="action" string="Just do it !"/>' +
- '<button name="object" type="object" string="Just don\'t do it !"/>' +
- '<field name="display_name"/>' +
- '</header>' +
- '</form>',
- },
- });
-
- form.$buttons.find('.o_form_button_edit').click();
- form.$('td.o_data_cell:first').click(); // edit first one2many line
- form.$('.o_external_button').click(); // open product sub view in modal
- $('button:contains("Just do it !")').click(); // click on action button
- $('button:contains("Just don\'t do it !")').click(); // click on object button
-
- form.destroy();
- });
-
- QUnit.test('X2Many sequence list in modal', function (assert) {
- assert.expect(5);
-
- this.data.partner.fields.sequence = {string: 'Sequence', type: 'integer'};
- this.data.partner.records[0].sequence = 1;
- this.data.partner.records[1].sequence = 2;
- this.data.partner.onchanges = {
- sequence: function (obj) {
- if (obj.id === 2) {
- obj.sequence = 1;
- assert.step('onchange sequence');
- }
- },
- };
-
- this.data.product.fields.turtle_ids = {string: 'Turtles', type: 'one2many', relation: 'turtle'};
- this.data.product.records[0].turtle_ids = [1];
-
- this.data.turtle.fields.partner_types_ids = {string: "Partner", type: "one2many", relation: 'partner'};
- this.data.turtle.fields.type_id = {string: "Partner Type", type: "many2one", relation: 'partner_type'};
-
- this.data.partner_type.fields.partner_ids = {string: "Partner", type: "one2many", relation: 'partner'};
- this.data.partner_type.records[0].partner_ids = [1,2];
-
- testUtils.createAsyncView({
- View: FormView,
- model: 'product',
- data: this.data,
- arch: '<form>' +
- '<field name="name"/>' +
- '<field name="turtle_ids" widget="one2many_list">' +
- '<tree string="Turtles" editable="bottom">' +
- '<field name="type_id"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- archs: {
- 'partner_type,false,form': '<form><field name="partner_ids"/></form>',
- 'partner,false,list': '<tree string="Vendors">' +
- '<field name="display_name"/>' +
- '<field name="sequence" widget="handle"/>' +
- '</tree>',
- },
- res_id: 37,
- mockRPC: function (route, args) {
- if (route === '/web/dataset/call_kw/product/read') {
- return $.when([{id: 37, name: 'xphone', display_name: 'leonardo', turtle_ids: [1]}]);
- }
- if (route === '/web/dataset/call_kw/turtle/read') {
- return $.when([{id: 1, type_id: [12, 'gold']}]);
- }
- if (route === '/web/dataset/call_kw/partner_type/get_formview_id') {
- return $.when(false);
- }
- if (route === '/web/dataset/call_kw/partner_type/read') {
- return $.when([{id: 12, partner_ids: [1,2], display_name: 'gold'}]);
- }
- if (route === '/web/dataset/call_kw/partner_type/write') {
- assert.step('partner_type write');
- }
- return this._super.apply(this, arguments);
- },
- }).then(function(form) {
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_data_cell').click();
- form.$('.o_external_button').click();
-
- var $modal = $('.modal');
- assert.equal($modal.length, 1,
- 'There should be 1 modal opened');
-
- var $handles = $modal.find('.ui-sortable-handle');
- assert.equal($handles.length, 2,
- 'There should be 2 sequence handlers');
-
- testUtils.dragAndDrop($handles.eq(1), $modal.find('tbody tr').first(),
- {position: 'top'});
-
- // Saving the modal and then the original model
- $modal.find('.modal-footer .btn-primary').click();
- form.$buttons.find('.o_form_button_save').click();
-
- assert.verifySteps(['onchange sequence', 'partner_type write']);
-
- form.destroy();
- });
- });
-
- QUnit.test('autocompletion in a many2one, in form view with a domain', function (assert) {
- assert.expect(1);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form>' +
- '<field name="product_id"/>' +
- '</form>',
- res_id: 1,
- viewOptions: {
- domain: [['trululu', '=', 4]]
- },
- mockRPC: function (route, args) {
- if (args.method === 'name_search') {
- assert.deepEqual(args.kwargs.args, [], "should not have a domain");
- }
- return this._super(route, args);
- }
- });
- form.$buttons.find('.o_form_button_edit').click();
-
- form.$('input').click();
- form.destroy();
- });
-
- QUnit.test('autocompletion in a many2one, in form view with a date field', function (assert) {
- assert.expect(1);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form>' +
- '<field name="bar"/>' +
- '<field name="date"/>' +
- '<field name="trululu" domain="[(\'bar\',\'=\',True)]"/>' +
- '</form>',
- res_id: 2,
- mockRPC: function (route, args) {
- if (args.method === 'name_search') {
- assert.deepEqual(args.kwargs.args, [["bar", "=", true]], "should not have a domain");
- }
- return this._super(route, args);
- },
- });
- form.$buttons.find('.o_form_button_edit').click();
-
- form.$('input:eq(2)').click();
- form.destroy();
- });
-
- QUnit.test('creating record with many2one with option always_reload', function (assert) {
- assert.expect(2);
-
- this.data.partner.fields.trululu.default = 1;
- this.data.partner.onchanges = {
- trululu: function (obj) {
- obj.trululu = 2; //[2, "second record"];
- },
- };
-
- var count = 0;
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form>' +
- '<field name="trululu" options="{\'always_reload\': True}"/>' +
- '</form>',
- mockRPC: function (route, args) {
- count++;
- if (args.method === 'name_get' && args.args[0][0] === 2) {
- return $.when([[2, "hello world\nso much noise"]]);
- }
- return this._super(route, args);
- },
- });
-
- assert.strictEqual(count, 3, "should have done 3 rpcs (default_get, onchange, name_get)");
- assert.strictEqual(form.$('input').val(), 'hello world',
- "should have taken the correct display name");
- form.destroy();
- });
-
- QUnit.test('selecting a many2one, then discarding', function (assert) {
- assert.expect(3);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="product_id"/>' +
- '</form>',
- res_id: 1,
- });
- assert.strictEqual(form.$('a').text(), '', 'the tag a should be empty');
- form.$buttons.find('.o_form_button_edit').click();
-
- form.$('.o_field_many2one input').click();
- form.$('.o_field_many2one input').autocomplete('widget').find('a').first().click();
-
-
- assert.strictEqual(form.$('input').val(), "xphone", "should have selected xphone");
-
- form.$buttons.find('.o_form_button_cancel').click();
- assert.strictEqual(form.$('a').text(), '', 'the tag a should be empty');
- form.destroy();
- });
-
- QUnit.test('domain and context are correctly used when doing a name_search in a m2o', function (assert) {
- assert.expect(4);
-
- this.data.partner.records[0].timmy = [12];
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:
- '<form string="Partners">' +
- '<field name="product_id" ' +
- 'domain="[[\'foo\', \'=\', \'bar\'], [\'foo\', \'=\', foo]]" ' +
- 'context="{\'hello\': \'world\', \'test\': foo}"/>' +
- '<field name="foo"/>' +
- '<field name="trululu" context="{\'timmy\': timmy}" domain="[[\'id\', \'in\', timmy]]"/>' +
- '<field name="timmy" widget="many2many_tags" invisible="1"/>' +
- '</form>',
- res_id: 1,
- session: {user_context: {hey: "ho"}},
- mockRPC: function (route, args) {
- if (args.method === 'name_search' && args.model === 'product') {
- assert.deepEqual(
- args.kwargs.args,
- [['foo', '=', 'bar'], ['foo', '=', 'yop']],
- 'the field attr domain should have been used for the RPC (and evaluated)');
- assert.deepEqual(
- args.kwargs.context,
- {hey: "ho", hello: "world", test: "yop"},
- 'the field attr context should have been used for the ' +
- 'RPC (evaluated and merged with the session one)');
- return $.when([]);
- }
- if (args.method === 'name_search' && args.model === 'partner') {
- assert.deepEqual(args.kwargs.args, [['id', 'in', [12]]],
- 'the field attr domain should have been used for the RPC (and evaluated)');
- assert.deepEqual(args.kwargs.context, {hey: 'ho', timmy: [[6, false, [12]]]},
- 'the field attr context should have been used for the RPC (and evaluated)');
- return $.when([]);
- }
- return this._super.apply(this, arguments);
- },
- });
-
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_field_widget[name=product_id] input').click();
-
- form.$('.o_field_widget[name=trululu] input').click();
-
- form.destroy();
- });
-
- QUnit.test('quick create on a many2one', function (assert) {
- assert.expect(1);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<sheet>' +
- '<field name="product_id"/>' +
- '</sheet>' +
- '</form>',
- mockRPC: function (route, args) {
- if (route === '/web/dataset/call_kw/product/name_create') {
- assert.strictEqual(args.args[0], 'new partner',
- "should name create a new product");
- }
- return this._super.apply(this, arguments);
- },
- });
-
- form.$('.o_field_many2one input').focus();
- form.$('.o_field_many2one input').val('new partner').trigger('keyup').trigger('focusout');
-
- $('.modal .modal-footer .btn-primary').first().click();
-
- form.destroy();
- });
-
- QUnit.test('slow create on a many2one', function (assert) {
- assert.expect(7);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:
- '<form>' +
- '<sheet>' +
- '<field name="product_id" options="{\'quick_create\': False}"/>' +
- '</sheet>' +
- '</form>',
- archs: {
- 'product,false,form':
- '<form>' +
- '<field name="name"/>' +
- '</form>',
- },
- });
-
- // cancel the many2one creation with Cancel button
- form.$('.o_field_many2one input').focus().val('new product').trigger('keyup').trigger('blur');
- assert.strictEqual($('.modal').length, 1, "there should be one opened modal");
- $('.modal .modal-footer .btn:contains(Cancel)').click();
- assert.strictEqual(form.$('.o_field_many2one input').val(), "",
- 'the many2one should not set a value as its creation has been cancelled (with Cancel button)');
-
- // cancel the many2one creation with Close button
- form.$('.o_field_many2one input').focus().val('new product').trigger('keyup').trigger('blur');
- $('.modal .modal-header button').click();
- assert.strictEqual(form.$('.o_field_many2one input').val(), "",
- 'the many2one should not set a value as its creation has been cancelled (with Close button)');
-
- // select a new value then cancel the creation of the new one --> restore the previous
- form.$('.o_field_many2one input').click();
- form.$('.o_field_many2one input').autocomplete('widget').find('a').first().click();
- assert.strictEqual(form.$('input').val(), "xphone", "should have selected xphone");
- form.$('.o_field_many2one input').focus().val('new product').trigger('keyup').trigger('blur');
- assert.strictEqual($('.modal').length, 1, "there should be one opened modal");
- $('.modal .modal-footer .btn:contains(Cancel)').click();
- assert.strictEqual(form.$('.o_field_many2one input').val(), "xphone",
- 'should have restored the many2one with its previous selected value (xphone)');
-
- // confirm the many2one creation
- form.$('.o_field_many2one input').focus();
- form.$('.o_field_many2one input').val('new partner').trigger('keyup').trigger('blur');
- $('.modal .modal-footer .btn-primary').click();
- assert.strictEqual($('.modal .o_form_view').length, 1,
- 'a new modal should be opened and contain a form view');
-
- form.destroy();
- });
-
- QUnit.test('no_create option on a many2one', function (assert) {
- assert.expect(1);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<sheet>' +
- '<field name="product_id" options="{\'no_create\': True}"/>' +
- '</sheet>' +
- '</form>',
- });
-
- form.$('.o_field_many2one input').focus();
- form.$('.o_field_many2one input').val('new partner').trigger('keyup').trigger('focusout');
-
- assert.strictEqual($('.modal').length, 0, "should not display the create modal");
- form.destroy();
- });
-
- QUnit.test('can_create and can_write option on a many2one', function (assert) {
- assert.expect(5);
-
- this.data.product.options = {
- can_create: "false",
- can_write: "false",
- };
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<sheet>' +
- '<field name="product_id" can_create="false" can_write="false"/>' +
- '</sheet>' +
- '</form>',
- archs: {
- 'product,false,form': '<form string="Products"><field name="display_name"/></form>',
- },
- mockRPC: function (route) {
- if (route === '/web/dataset/call_kw/product/get_formview_id') {
- return $.when(false);
- }
- return this._super.apply(this, arguments);
- },
- });
-
- form.$('.o_field_many2one input').click();
- assert.strictEqual($('.ui-autocomplete .o_m2o_dropdown_option:contains(Create)').length, 0,
- "there shouldn't be any option to search and create");
-
- $('.ui-autocomplete li:contains(xpad)').mouseenter().click();
- assert.strictEqual(form.$('.o_field_many2one input').val(), "xpad",
- "the correct record should be selected");
- assert.strictEqual(form.$('.o_field_many2one .o_external_button').length, 1,
- "there should be an external button displayed");
-
- form.$('.o_field_many2one .o_external_button').click();
- assert.strictEqual($('.modal .o_form_view.o_form_readonly').length, 1,
- "there should be a readonly form view opened");
-
- $('.modal .o_form_button_cancel').click();
-
- form.$('.o_field_many2one input').val('new product').trigger('keyup').trigger('focusout');
-
- assert.strictEqual($('.modal').length, 0, "should not display the create modal");
- form.destroy();
- });
-
- QUnit.test('pressing enter in a m2o in an editable list', function (assert) {
- assert.expect(9);
- var done = assert.async();
- var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
- relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
-
- var list = createView({
- View: ListView,
- model: 'partner',
- data: this.data,
- arch: '<tree editable="bottom"><field name="product_id"/></tree>',
- });
-
- list.$('td.o_data_cell:first').click();
- assert.strictEqual(list.$('.o_selected_row').length, 1,
- "should have a row in edit mode");
-
- // we now write 'a' and press enter to check that the selection is
- // working, and prevent the navigation
- list.$('td.o_data_cell input:first').val('a').trigger('input');
- concurrency.delay(0).then(function () {
- var $input = list.$('td.o_data_cell input:first');
- var $dropdown = $input.autocomplete('widget');
- assert.ok($dropdown.is(':visible'), "autocomplete dropdown should be visible");
-
- // we now trigger ENTER to select first choice
- $input.trigger($.Event('keydown', {
- which: $.ui.keyCode.ENTER,
- keyCode: $.ui.keyCode.ENTER,
- }));
- assert.strictEqual($input[0], document.activeElement,
- "input should still be focused");
-
- // we now trigger again ENTER to make sure we can move to next line
- $input.trigger($.Event('keydown', {
- which: $.ui.keyCode.ENTER,
- keyCode: $.ui.keyCode.ENTER,
- }));
-
- assert.notOk(document.contains($input[0]),
- "input should no longer be in dom");
- assert.ok(list.$('tr.o_data_row:eq(1)').hasClass('o_selected_row'),
- "second row should now be selected");
-
- // we now write again 'a' in the cell to select xpad. We will now
- // test with the tab key
- list.$('td.o_data_cell input:first').val('a').trigger('input');
- return concurrency.delay(0);
- }).then(function () {
- var $input = list.$('td.o_data_cell input:first');
- var $dropdown = $input.autocomplete('widget');
- assert.ok($dropdown.is(':visible'), "autocomplete dropdown should be visible");
- $input.trigger($.Event('keydown', {
- which: $.ui.keyCode.TAB,
- keyCode: $.ui.keyCode.TAB,
- }));
- assert.strictEqual($input[0], document.activeElement,
- "input should still be focused");
-
- // we now trigger again ENTER to make sure we can move to next line
- $input.trigger($.Event('keydown', {
- which: $.ui.keyCode.TAB,
- keyCode: $.ui.keyCode.TAB,
- }));
-
- assert.notOk(document.contains($input[0]),
- "input should no longer be in dom");
- assert.ok(list.$('tr.o_data_row:eq(2)').hasClass('o_selected_row'),
- "third row should now be selected");
- list.destroy();
- relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
- done();
- });
- });
-
- QUnit.test('pressing ENTER on a \'no_quick_create\' many2one should open a M2ODialog', function (assert) {
- var done = assert.async();
- assert.expect(2);
-
- var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
- relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form>' +
- '<field name="trululu" options="{\'no_quick_create\': True}"/>' +
- '<field name="foo"/>' +
- '</form>',
- archs: {
- 'partner,false,form': '<form string="Partners"><field name="display_name"/></form>',
- },
- });
-
- var $input = form.$('.o_field_many2one input');
- $input.val("Something that does not exist").trigger('input');
- $('.ui-autocomplete .ui-menu-item a:contains(Create and)').trigger('mouseenter');
- concurrency.delay(0).then(function() {
- $input.trigger($.Event('keydown', {
- which: $.ui.keyCode.ENTER,
- keyCode: $.ui.keyCode.ENTER,
- }));
- $input.trigger($.Event('keypress', {
- which: $.ui.keyCode.ENTER,
- keyCode: $.ui.keyCode.ENTER,
- }));
- $input.trigger($.Event('keyup', {
- which: $.ui.keyCode.ENTER,
- keyCode: $.ui.keyCode.ENTER,
- }));
- concurrency.delay(0).then(function() {
- $input.blur();
- assert.strictEqual($('.modal').length, 1,
- "should have one modal in body");
- // Check that discarding clears $input
- $('.modal .o_form_button_cancel').click();
- assert.strictEqual($input.val(), '',
- "the field should be empty");
- form.destroy();
- relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
- done();
- });
- });
- });
-
- QUnit.test('select a value by pressing TAB on a many2one with onchange', function (assert) {
- var done = assert.async();
- assert.expect(3);
-
- this.data.partner.onchanges.trululu = function () {};
-
- var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
- relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
- var def = $.Deferred();
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form>' +
- '<field name="trululu"/>' +
- '<field name="display_name"/>' +
- '</form>',
- mockRPC: function (route, args) {
- var result = this._super.apply(this, arguments);
- if (args.method === 'onchange') {
- return $.when(def).then(_.constant(result));
- }
- return result;
- },
- res_id: 1,
- viewOptions: {
- mode: 'edit',
- },
- });
-
- var $input = form.$('.o_field_many2one input');
- $input.val("first").trigger('input');
- concurrency.delay(0).then(function () {
- var evOptions = {
- which: $.ui.keyCode.TAB,
- keyCode: $.ui.keyCode.TAB,
- };
- $input.trigger($.Event('keydown', evOptions));
- $input.trigger($.Event('keypress', evOptions));
- $input.trigger($.Event('keyup', evOptions));
-
- // simulate a focusout (e.g. because the user clicks outside)
- // before the onchange returns
- form.$('.o_field_char').focus();
-
- assert.strictEqual($('.modal').length, 0,
- "there shouldn't be any modal in body");
-
- // unlock the onchange
- def.resolve();
-
- assert.strictEqual($input.val(), 'first record',
- "first record should have been selected");
- assert.strictEqual($('.modal').length, 0,
- "there shouldn't be any modal in body");
- relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
- form.destroy();
- done();
- });
- });
-
- QUnit.test('many2one in editable list + onchange, with enter [REQUIRE FOCUS]', function (assert) {
- assert.expect(6);
- var done = assert.async();
- var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
- relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
-
- this.data.partner.onchanges.product_id = function (obj) {
- obj.int_field = obj.product_id || 0;
- };
-
- var def = $.Deferred();
-
- var list = createView({
- View: ListView,
- model: 'partner',
- data: this.data,
- arch: '<tree editable="bottom"><field name="product_id"/><field name="int_field"/></tree>',
- mockRPC: function (route, args) {
- if (args.method) {
- assert.step(args.method);
- }
- var result = this._super.apply(this, arguments);
- if (args.method === 'onchange') {
- return def.then(_.constant(result));
- }
- return result;
- },
- });
-
- list.$('td.o_data_cell:first').click();
- list.$('td.o_data_cell input:first').val('a').trigger('input');
- concurrency.delay(0).then(function () {
- var $input = list.$('td.o_data_cell input:first');
- $input.trigger($.Event('keydown', {
- which: $.ui.keyCode.ENTER,
- keyCode: $.ui.keyCode.ENTER,
- }));
- $input.trigger($.Event('keyup', {
- which: $.ui.keyCode.ENTER,
- keyCode: $.ui.keyCode.ENTER,
- }));
- def.resolve();
- $input.trigger($.Event('keydown', {
- which: $.ui.keyCode.ENTER,
- keyCode: $.ui.keyCode.ENTER,
- }));
- assert.strictEqual($('.modal').length, 0, "should not have any modal in DOM");
- assert.verifySteps(['name_search', 'onchange', 'write', 'read']);
- list.destroy();
- relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
- done();
- });
- });
-
- QUnit.test('many2one in editable list + onchange, with enter, part 2 [REQUIRE FOCUS]', function (assert) {
- // this is the same test as the previous one, but the onchange is just
- // resolved slightly later
- assert.expect(6);
- var done = assert.async();
- var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
- relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
-
- this.data.partner.onchanges.product_id = function (obj) {
- obj.int_field = obj.product_id || 0;
- };
-
- var def = $.Deferred();
-
- var list = createView({
- View: ListView,
- model: 'partner',
- data: this.data,
- arch: '<tree editable="bottom"><field name="product_id"/><field name="int_field"/></tree>',
- mockRPC: function (route, args) {
- if (args.method) {
- assert.step(args.method);
- }
- var result = this._super.apply(this, arguments);
- if (args.method === 'onchange') {
- return def.then(_.constant(result));
- }
- return result;
- },
- });
-
- list.$('td.o_data_cell:first').click();
- list.$('td.o_data_cell input:first').val('a').trigger('input');
- concurrency.delay(0).then(function () {
- var $input = list.$('td.o_data_cell input:first');
- $input.trigger($.Event('keydown', {
- which: $.ui.keyCode.ENTER,
- keyCode: $.ui.keyCode.ENTER,
- }));
- $input.trigger($.Event('keyup', {
- which: $.ui.keyCode.ENTER,
- keyCode: $.ui.keyCode.ENTER,
- }));
- $input.trigger($.Event('keydown', {
- which: $.ui.keyCode.ENTER,
- keyCode: $.ui.keyCode.ENTER,
- }));
- def.resolve();
- assert.strictEqual($('.modal').length, 0, "should not have any modal in DOM");
- assert.verifySteps(['name_search', 'onchange', 'write', 'read']);
- list.destroy();
- relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
- done();
- });
- });
-
- QUnit.test('many2one: domain updated by an onchange', function (assert) {
- assert.expect(2);
-
- this.data.partner.onchanges = {
- int_field: function () {},
- };
-
- var domain = [];
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form>' +
- '<field name="int_field"/>' +
- '<field name="trululu"/>' +
- '</form>',
- res_id: 1,
- mockRPC: function (route, args) {
- if (args.method === 'onchange') {
- domain = [['id', 'in', [10]]];
- return $.when({
- domain: {
- trululu: domain,
- unexisting_field: domain,
- }
- });
- }
- if (args.method === 'name_search') {
- assert.deepEqual(args.kwargs.args, domain,
- "sent domain should be correct");
- }
- return this._super(route, args);
- },
- viewOptions: {
- mode: 'edit',
- },
- });
-
- // trigger a name_search (domain should be [])
- form.$('.o_field_widget[name=trululu] input').click();
- // close the dropdown
- form.$('.o_field_widget[name=trululu] input').click();
- // trigger an onchange that will update the domain
- form.$('.o_field_widget[name=int_field]').val(2).trigger('input');
- // trigger a name_search (domain should be [['id', 'in', [10]]])
- form.$('.o_field_widget[name=trululu] input').click();
-
- form.destroy();
- });
-
- QUnit.test('many2one in one2many: domain updated by an onchange', function (assert) {
- assert.expect(3);
-
- this.data.partner.onchanges = {
- trululu: function () {},
- };
-
- var domain = [];
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form>' +
- '<field name="p">' +
- '<tree editable="bottom">' +
- '<field name="trululu"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- mockRPC: function (route, args) {
- if (args.method === 'onchange') {
- return $.when({
- domain: {
- trululu: domain,
- },
- });
- }
- if (args.method === 'name_search') {
- assert.deepEqual(args.kwargs.args, domain,
- "sent domain should be correct");
- }
- return this._super(route, args);
- },
- viewOptions: {
- mode: 'edit',
- },
- });
-
- // add a first row with a specific domain for the m2o
- domain = [['id', 'in', [10]]]; // domain for subrecord 1
- form.$('.o_field_x2many_list_row_add a').click(); // triggers the onchange
- form.$('.o_field_widget[name=trululu] input').click(); // triggers the name_search
-
- // add a second row with another domain for the m2o
- domain = [['id', 'in', [5]]]; // domain for subrecord 2
- form.$('.o_field_x2many_list_row_add a').click(); // triggers the onchange
- form.$('.o_field_widget[name=trululu] input').click(); // triggers the name_search
-
- // check again the first row to ensure that the domain hasn't change
- domain = [['id', 'in', [10]]]; // domain for subrecord 1 should have been kept
- form.$('.o_data_row:first .o_data_cell').click();
- form.$('.o_field_widget[name=trululu] input').click(); // triggers the name_search
-
- form.destroy();
- });
-
- QUnit.test('updating a many2one from a many2many', function (assert) {
- assert.expect(4);
-
- this.data.turtle.records[1].turtle_trululu = 1;
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<group>' +
- '<field name="turtles">' +
- '<tree editable="bottom">' +
- '<field name="display_name"/>' +
- '<field name="turtle_trululu"/>' +
- '</tree>' +
- '</field>' +
- '</group>' +
- '</form>',
- res_id: 1,
- archs: {
- 'partner,false,form': '<form string="Trululu"><field name="display_name"/></form>',
- },
- mockRPC: function (route, args) {
- if (args.method === 'get_formview_id') {
- assert.deepEqual(args.args[0], [1], "should call get_formview_id with correct id");
- return $.when(false);
- }
- return this._super(route, args);
- },
- });
-
- // Opening the modal
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_data_row td:contains(first record)').click();
- form.$('.o_external_button').click();
- assert.strictEqual($('.modal').length, 1,
- "should have one modal in body");
-
- // Changing the 'trululu' value
- $('.modal input[name="display_name"]').val('test').trigger('input');
- $('.modal button.btn-primary').click();
-
- // Test whether the value has changed
- assert.strictEqual($('.modal').length, 0,
- "the modal should be closed");
- assert.equal(form.$('.o_data_cell:contains(test)').text(), 'test',
- "the partner name should have been updated to 'test'");
-
- form.destroy();
- });
-
- QUnit.test('x2many list sorted by many2one', function (assert) {
- assert.expect(3);
-
- this.data.partner.records[0].p = [1, 2, 4];
- this.data.partner.fields.trululu.sortable = true;
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form>' +
- '<field name="p">' +
- '<tree>' +
- '<field name="id"/>' +
- '<field name="trululu"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- });
-
- assert.strictEqual(form.$('.o_data_row .o_list_number').text(), '124',
- "should have correct order initially");
-
- form.$('.o_list_view thead th:nth(1)').click();
-
- assert.strictEqual(form.$('.o_data_row .o_list_number').text(), '412',
- "should have correct order (ASC)");
-
- form.$('.o_list_view thead th:nth(1)').click();
-
- assert.strictEqual(form.$('.o_data_row .o_list_number').text(), '214',
- "should have correct order (DESC)");
-
- form.destroy();
- });
-
- QUnit.test('many2many list add *many* records, remove, re-add', function (assert) {
- assert.expect(5);
-
- this.data.partner.fields.timmy.domain = [['color', '=', 2]];
- this.data.partner.fields.timmy.onChange = true;
- this.data.partner_type.fields.product_ids = {string: "Product", type: "many2many", relation: 'product'};
-
- for (var i=0; i<50; i++) {
- var new_record_partner_type = {id: 100+i, display_name: "batch" + i, color: 2};
- this.data.partner_type.records.push(new_record_partner_type);
- }
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="timmy" widget="many2many">' +
- '<tree>' +
- '<field name="display_name"/>' +
- '<field name="product_ids" widget="many2many_tags"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- archs: {
- 'partner_type,false,list': '<tree><field name="display_name"/></tree>',
- 'partner_type,false,search': '<search><field name="display_name"/><field name="color"/></search>',
- },
- mockRPC: function (route, args) {
- if (args.method === 'get_formview_id') {
- assert.deepEqual(args.args[0], [1], "should call get_formview_id with correct id");
- return $.when(false);
- }
- return this._super(route, args);
- },
- });
-
- // First round: add 51 records in batch
- form.$buttons.find('.btn.btn-primary.o_form_button_edit').click();
- form.$('.o_field_x2many_list_row_add a').click();
-
- var $modal = $('.modal-lg');
-
- assert.equal($modal.length, 1,
- 'There should be one modal');
-
- $modal.find('thead input[type=checkbox]').click(); //select all the records we created in batch + 'gold'
-
- $modal.find('.btn.btn-primary.o_select_button').click();
-
- var m2m_records = form.$('.o_field_many2many.o_field_widget.o_field_x2many.o_field_x2many_list .o_data_cell:not(.o_many2many_tags_cell)');
- assert.equal(m2m_records.length, 51,
- 'We should have added all the records present in the search view to the m2m field'); // the 50 in batch + 'gold'
-
- form.$buttons.find('.btn.btn-primary.o_form_button_save').click();
-
- // Secound round: remove one record
- form.$buttons.find('.btn.btn-primary.o_form_button_edit').click();
- var trash_buttons = form.$('.o_field_many2many.o_field_widget.o_field_x2many.o_field_x2many_list .o_list_record_remove');
-
- trash_buttons.first().click();
-
- var pager_limit = form.$('.o_field_many2many.o_field_widget.o_field_x2many.o_field_x2many_list .o_pager_limit');
- assert.equal(pager_limit.text(), '50',
- 'We should have 50 records in the m2m field');
-
- // Third round: re-add 1 records
- form.$('.o_field_x2many_list_row_add a').click();
-
- $modal = $('.modal-lg');
-
- assert.equal($modal.length, 1,
- 'There should be one modal');
-
- $modal.find('thead input[type=checkbox]').click();
-
- $modal.find('.btn.btn-primary.o_select_button').click();
-
- pager_limit = form.$('.o_field_many2many.o_field_widget.o_field_x2many.o_field_x2many_list .o_pager_limit');
- assert.equal(pager_limit.text(), '51',
- 'We should have 51 records in the m2m field');
-
- form.destroy();
- });
-
- QUnit.module('FieldOne2Many');
-
- QUnit.test('New record with a o2m also with 2 new records, ordered, and resequenced', function (assert) {
- assert.expect(3);
-
- // Needed to have two new records in a single stroke
- this.data.partner.onchanges = {
- foo: function(obj) {
- obj.p = [
- [5],
- [0, 0, {trululu: false}],
- [0, 0, {trululu: false}],
- ]
- }
- };
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="foo" />' +
- '<field name="p">' +
- '<tree editable="bottom" default_order="int_field">' +
- '<field name="int_field" widget="handle"/>' +
- '<field name="trululu"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- viewOptions: {
- mode: 'create',
- },
- mockRPC: function (route, args) {
- assert.step(args.method + ' ' + args.model)
- return this._super(route, args);
- },
- });
-
- // change the int_field through drag and drop
- // that way, we'll trigger the sorting and the name_get
- // of the lines of "p"
- testUtils.dragAndDrop(
- form.$('.ui-sortable-handle').eq(1),
- form.$('tbody tr').first(),
- {position: 'top'}
- );
-
- // Only those two should have been called
- // name_get on trululu would trigger an traceback
- assert.verifySteps(['default_get partner', 'onchange partner']);
-
- form.destroy();
- });
-
- QUnit.test('O2M List with pager, decoration and default_order: add and cancel adding', function (assert) {
- assert.expect(3);
-
- // The decoration on the list implies that its condition will be evaluated
- // against the data of the field (actual records *displayed*)
- // If one data is wrongly formed, it will crash
- // This test adds then cancels a record in a paged, ordered, and decorated list
- // That implies prefetching of records for sorting
- // and evaluation of the decoration against *visible records*
-
- this.data.partner.records[0].p = [2,4];
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="p">' +
- '<tree editable="bottom" limit="1" decoration-muted="foo != False" default_order="display_name">' +
- '<field name="foo" invisible="1"/>' +
- '<field name="display_name" />' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- viewOptions: {
- mode: 'edit',
- },
- });
-
- form.$('.o_field_x2many_list .o_field_x2many_list_row_add a').click();
-
- var $dataRows = form.$('.o_field_x2many_list .o_data_row');
- assert.equal($dataRows.length, 2,
- 'There should be 2 rows');
-
- var $expectedSelectedRow = $dataRows.eq(1);
- var $actualSelectedRow = form.$('.o_selected_row');
- assert.equal($actualSelectedRow[0], $expectedSelectedRow[0],
- 'The selected row should be the new one');
-
- // Cancel Creation
- var escapeKey = $.ui.keyCode.ESCAPE;
- $actualSelectedRow.find('input').trigger(
- $.Event('keydown', {which: escapeKey, keyCode: escapeKey}));
-
- $dataRows = form.$('.o_field_x2many_list .o_data_row');
- assert.equal($dataRows.length, 1,
- 'There should be 1 row');
-
- form.destroy();
- });
-
- QUnit.test('O2M with parented m2o and domain on parent.m2o', function (assert) {
- assert.expect(3);
-
- /* records in an o2m can have a m2o pointing to themselves
- * in that case, a domain evaluation on that field followed by name_search
- * shouldn't send virtual_ids to the server
- */
-
- this.data.turtle.fields.parent_id = {string: "Parent", type: "many2one", relation: 'turtle'};
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="turtles">' +
- '<tree>' +
- '<field name="parent_id" />' +
- '</tree>' +
- '</field>' +
- '</form>',
- archs: {
- 'turtle,false,form': '<form><field name="parent_id" domain="[(\'id\', \'in\', parent.turtles)]"/></form>',
- },
-
- mockRPC: function(route, args) {
- if (route === '/web/dataset/call_kw/turtle/name_search') {
- // We are going to pass twice here
- // First time, we really have nothing
- // Second time, a virtual_id has been created
- assert.deepEqual(args.kwargs.args, [['id', 'in', []]]);
- }
- return this._super(route, args);
- }
- });
-
- form.$('.o_field_x2many_list[name=turtles] .o_field_x2many_list_row_add a').click();
- var $modal = $('.modal-content');
-
- var $turtleParent = $modal.find('.o_field_many2one input');
- var $dropdown = $turtleParent.autocomplete('widget');
-
- $turtleParent.click();
-
- $dropdown.find('li.o_m2o_dropdown_option:contains(Create)').first().mouseenter().click();
-
- $modal = $('.modal-content');
-
- $modal.eq(1).find('.modal-footer .btn-primary').eq(0).click(); // Confirm new Record
- $modal.eq(0).find('.modal-footer .btn-primary').eq(1).click(); // Save & New
-
- assert.equal(form.$('.o_data_row').length, 1,
- 'The main record should have the new record in its o2m');
-
- $modal = $('.modal-content');
- $modal.find('.o_field_many2one input').click();
-
- form.destroy();
- });
-
- QUnit.test('one2many list editable with cell readonly modifier', function (assert) {
- assert.expect(4);
-
- this.data.partner.records[0].p = [2];
- this.data.partner.records[1].turtles = [1,2];
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="p">' +
- '<tree editable="bottom">' +
- '<field name="turtles" invisible="1"/>' +
- '<field name="foo" attrs="{&quot;readonly&quot; : [(&quot;turtles&quot;, &quot;!=&quot;, [])] }"/>' +
- '<field name="qux" attrs="{&quot;readonly&quot; : [(&quot;turtles&quot;, &quot;!=&quot;, [])] }"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- mockRPC: function (route, args) {
- if (route === '/web/dataset/call_kw/partner/write') {
- assert.deepEqual(args.args[1].p[1][2], {foo: 'ff', qux: 99},
- 'The right values should be written');
- }
- return this._super(route, args);
- }
- });
-
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_field_x2many_list_row_add a').click();
-
- var $targetInput = $('.o_selected_row .o_input[name=foo]');
- assert.equal($targetInput[0], document.activeElement,
- 'The first input of the line should have the focus');
-
- // Simulating hitting the 'f' key twice
- $targetInput.val('f').trigger('input');
- $targetInput.val($targetInput.val() + 'f').trigger('input');
-
- assert.equal($targetInput[0], document.activeElement,
- 'The first input of the line should still have the focus');
-
- // Simulating a TAB key
- $targetInput.trigger($.Event('keydown', {which: 9, keyCode:9}));
-
- var $secondTarget = $('.o_selected_row .o_input[name=qux]');
-
- assert.equal($secondTarget[0], document.activeElement,
- 'The second input of the line should have the focus after the TAB press');
-
-
- $secondTarget.val(9).trigger('input');
- $secondTarget.val($secondTarget.val() + 9).trigger('input');
-
- form.$buttons.find('.o_form_button_save').click();
-
- form.destroy();
- });
-
- QUnit.test('one2many basic properties', function (assert) {
- assert.expect(6);
-
- this.data.partner.records[0].p = [2];
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<sheet>' +
- '<notebook>' +
- '<page string="Partner page">' +
- '<field name="p">' +
- '<tree>' +
- '<field name="foo"/>' +
- '</tree>' +
- '</field>' +
- '</page>' +
- '</notebook>' +
- '</sheet>' +
- '</form>',
- res_id: 1,
- });
-
-
- assert.strictEqual(form.$('td.o_list_record_selector').length, 0,
- "embedded one2many should not have a selector");
- assert.ok(!form.$('.o_field_x2many_list_row_add').length,
- "embedded one2many should not be editable");
- assert.ok(!form.$('td.o_list_record_remove').length,
- "embedded one2many records should not have a remove icon");
-
- form.$buttons.find('.o_form_button_edit').click();
-
- assert.ok(form.$('.o_field_x2many_list_row_add').length,
- "embedded one2many should now be editable");
-
- assert.strictEqual(form.$('.o_field_x2many_list_row_add').attr('colspan'), "2",
- "should have colspan 2 (one for field foo, one for being below remove icon)");
-
- assert.ok(form.$('td.o_list_record_remove').length,
- "embedded one2many records should have a remove icon");
- form.destroy();
- });
-
- QUnit.test('transferring class attributes in one2many sub fields', function (assert) {
- assert.expect(3);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="turtles">' +
- '<tree editable="bottom">' +
- '<field name="turtle_foo" class="hey"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- });
-
- assert.strictEqual(form.$('td.hey').length, 1,
- 'should have a td with the desired class');
-
- form.$buttons.find('.o_form_button_edit').click();
-
- assert.strictEqual(form.$('td.hey').length, 1,
- 'should have a td with the desired class');
-
- form.$('td.o_data_cell').click();
-
- assert.strictEqual(form.$('input[name="turtle_foo"].hey').length, 1,
- 'should have an input with the desired class');
-
- form.destroy();
- });
-
- QUnit.test('one2many with date and datetime', function (assert) {
- assert.expect(2);
-
- this.data.partner.records[0].p = [2];
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<sheet>' +
- '<notebook>' +
- '<page string="Partner page">' +
- '<field name="p">' +
- '<tree>' +
- '<field name="date"/>' +
- '<field name="datetime"/>' +
- '</tree>' +
- '</field>' +
- '</page>' +
- '</notebook>' +
- '</sheet>' +
- '</form>',
- res_id: 1,
- session: {
- getTZOffset: function () {
- return 120;
- },
- },
- });
- assert.strictEqual(form.$('td:eq(0)').text(), "01/25/2017",
- "should have formatted the date");
- assert.strictEqual(form.$('td:eq(1)').text(), "12/12/2016 12:55:05",
- "should have formatted the datetime");
- form.destroy();
- });
-
- QUnit.test('rendering with embedded one2many', function (assert) {
- assert.expect(2);
-
- this.data.partner.records[0].p = [2];
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<sheet>' +
- '<notebook>' +
- '<page string="P page">' +
- '<field name="p">' +
- '<tree>' +
- '<field name="foo"/>' +
- '<field name="bar"/>' +
- '</tree>' +
- '</field>' +
- '</page>' +
- '</notebook>' +
- '</sheet>' +
- '</form>',
- res_id: 1,
- });
-
- assert.strictEqual(form.$('th:contains(Foo)').length, 1,
- "embedded one2many should have a column titled according to foo");
- assert.strictEqual(form.$('td:contains(blip)').length, 1,
- "embedded one2many should have a cell with relational value");
- form.destroy();
- });
-
- QUnit.test('use the limit attribute in arch (in field o2m inline tree view)', function (assert) {
- assert.expect(2);
-
- this.data.partner.records[0].turtles = [1,2,3];
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="turtles">' +
- '<tree limit="2">' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- mockRPC: function (route, args) {
- if (args.model === 'turtle') {
- assert.deepEqual(args.args[0], [1,2],
- 'should only load first 2 records');
- }
- return this._super.apply(this, arguments);
- },
- });
-
- assert.strictEqual(form.$('.o_data_row').length, 2,
- 'should display 2 data rows');
- form.destroy();
- });
-
- QUnit.test('use the limit attribute in arch (in field o2m non inline tree view)', function (assert) {
- assert.expect(2);
-
- this.data.partner.records[0].turtles = [1,2,3];
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="turtles"/>' +
- '</form>',
- archs: {
- 'turtle,false,list': '<tree limit="2"><field name="turtle_foo"/></tree>',
- },
- res_id: 1,
- mockRPC: function (route, args) {
- if (args.model === 'turtle' && args.method === 'read') {
- assert.deepEqual(args.args[0], [1,2],
- 'should only load first 2 records');
- }
- return this._super.apply(this, arguments);
- },
- });
-
- assert.strictEqual(form.$('.o_data_row').length, 2,
- 'should display 2 data rows');
- form.destroy();
- });
-
- QUnit.test('one2many with default_order on view not inline', function (assert) {
- assert.expect(1);
-
- this.data.partner.records[0].turtles = [1,2,3];
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<sheet>' +
- '<notebook>' +
- '<page string="Turtles">' +
- '<field name="turtles"/>' +
- '</page>' +
- '</notebook>' +
- '</sheet>' +
- '</form>',
- archs: {
- 'turtle,false,list': '<tree default_order="turtle_foo">' +
- '<field name="turtle_int"/>' +
- '<field name="turtle_foo"/>' +
- '</tree>',
- },
- res_id: 1,
- });
- assert.strictEqual(form.$('.o_field_one2many .o_list_view .o_data_row').text(), "9blip21kawa0yop",
- "the default order should be correctly applied");
- form.destroy();
- });
-
- QUnit.test('embedded one2many with widget', function (assert) {
- assert.expect(1);
-
- this.data.partner.records[0].p = [2];
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<sheet>' +
- '<notebook>' +
- '<page string="P page">' +
- '<field name="p">' +
- '<tree>' +
- '<field name="int_field" widget="handle"/>' +
- '<field name="foo"/>' +
- '</tree>' +
- '</field>' +
- '</page>' +
- '</notebook>' +
- '</sheet>' +
- '</form>',
- res_id: 1,
- });
-
- assert.strictEqual(form.$('span.o_row_handle').length, 1, "should have 1 handles");
- form.destroy();
- });
-
- QUnit.test('embedded one2many with handle widget', function (assert) {
- assert.expect(10);
-
- var nbConfirmChange = 0;
- testUtils.patch(ListRenderer, {
- confirmChange: function () {
- nbConfirmChange++;
- return this._super.apply(this, arguments);
- },
- });
-
- this.data.partner.records[0].turtles = [1, 2, 3];
-
- 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,
- });
-
- testUtils.intercept(form, "field_changed", function (event) {
- assert.step(event.data.changes.turtles.data.turtle_int.toString());
- }, true);
-
- 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();
-
- assert.strictEqual(form.$('td.o_data_cell:not(.o_handle_cell)').text(), "yopblipkawa",
- "should still have the 3 rows in the correct order");
- assert.strictEqual(nbConfirmChange, 0, "should not have confirmed any change yet");
-
- // 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(nbConfirmChange, 1, "should have confirmed changes only once");
- assert.verifySteps(["0", "1"],
- "sequences values should be incremental starting from the previous minimum one");
-
- assert.strictEqual(form.$('td.o_data_cell:not(.o_handle_cell)').text(), "blipyopkawa",
- "should have the 3 rows in the new order");
-
- form.$buttons.find('.o_form_button_save').click();
-
- assert.deepEqual(_.map(this.data.turtle.records, function(turtle) {
- return _.pick(turtle, 'id', 'turtle_foo', 'turtle_int');
- }), [
- {id: 1, turtle_foo: "yop", turtle_int: 1},
- {id: 2, turtle_foo: "blip", turtle_int:0},
- {id: 3, turtle_foo: "kawa", turtle_int:21}
- ], "should have save the changed sequence");
-
- assert.strictEqual(form.$('td.o_data_cell:not(.o_handle_cell)').text(), "blipyopkawa",
- "should still have the 3 rows in the new order");
-
- testUtils.unpatch(ListRenderer);
-
- form.destroy();
- });
-
- QUnit.test('onchange for embedded one2many in a one2many with a second page', function (assert) {
- assert.expect(1);
-
- this.data.turtle.fields.partner_ids.type = 'one2many';
- this.data.turtle.records[0].partner_ids = [1];
- // we need a second page, so we set two records and only display one per page
- this.data.partner.records[0].turtles = [1, 2];
-
- this.data.partner.onchanges = {
- turtles: function (obj) {
- obj.turtles = [
- [5],
- [1, 1, {
- turtle_foo: "hop",
- partner_ids: [[5], [4, 1]],
- }],
- [1, 2, {
- turtle_foo: "blip",
- partner_ids: [[5], [4, 2], [4, 4]],
- }],
- ];
- },
- };
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="turtles">' +
- '<tree editable="bottom" limit="1">' +
- '<field name="turtle_foo"/>' +
- '<field name="partner_ids" widget="many2many_tags"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- mockRPC: function (route, args) {
- if (args.method === 'write') {
- var expectedResultTurtles = [
- [1, 1, {
- turtle_foo: "hop",
- }],
- [1, 2, {
- partner_ids: [[4, 2, false], [4, 4, false]],
- turtle_foo: "blip",
- }],
- ];
- assert.deepEqual(args.args[1].turtles, expectedResultTurtles,
- "the right values should be written");
- }
- return this._super.apply(this, arguments);
- }
- });
-
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_data_cell').eq(1).click();
- var $cell = form.$('.o_selected_row .o_input[name=turtle_foo]');
- $cell.val("hop").trigger('change');
- form.$buttons.find('.o_form_button_save').click();
-
- form.destroy();
- });
-
- QUnit.test('onchange for embedded one2many in a one2many updated by server', function (assert) {
- // here we test that after an onchange, the embedded one2many field has
- // been updated by a new list of ids by the server response, to this new
- // list should be correctly sent back at save time
- assert.expect(3);
-
- this.data.turtle.fields.partner_ids.type = 'one2many';
- this.data.partner.records[0].turtles = [2];
- this.data.turtle.records[1].partner_ids = [2];
-
- this.data.partner.onchanges = {
- turtles: function (obj) {
- obj.turtles = [
- [5],
- [1, 2, {
- turtle_foo: "hop",
- partner_ids: [[5], [4, 2], [4, 4]],
- }],
- ];
- },
- };
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="turtles">' +
- '<tree editable="bottom">' +
- '<field name="turtle_foo"/>' +
- '<field name="partner_ids" widget="many2many_tags"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- mockRPC: function (route, args) {
- if (route === '/web/dataset/call_kw/partner/write') {
- var expectedResultTurtles = [
- [1, 2, {
- partner_ids: [[4, 2, false], [4, 4, false]],
- turtle_foo: "hop",
- }],
- ];
- assert.deepEqual(args.args[1].turtles, expectedResultTurtles,
- 'The right values should be written');
- }
- return this._super.apply(this, arguments);
- }
- });
-
- assert.deepEqual(form.$('.o_many2many_tags_cell').text().trim(), "second record",
- "the partner_ids should be as specified at initialization");
-
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_data_cell').eq(1).click();
- var $cell = form.$('.o_selected_row .o_input[name=turtle_foo]');
- $cell.val("hop").trigger("change");
- form.$buttons.find('.o_form_button_save').click();
-
- assert.deepEqual(form.$('.o_many2many_tags_cell').text().trim().split(/\s+/),
- [ "second", "record", "aaa" ],
- 'The partner_ids should have been updated');
-
- form.destroy();
- });
-
- QUnit.test('onchange for embedded one2many with handle widget', function (assert) {
- assert.expect(2);
-
- this.data.partner.records[0].turtles = [1, 2, 3];
- var partnerOnchange = 0;
- this.data.partner.onchanges = {
- turtles: function (obj) {
- partnerOnchange++;
- },
- };
- var turtleOnchange = 0;
- this.data.turtle.onchanges = {
- turtle_int: function (obj) {
- 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,
- });
-
- form.$buttons.find('.o_form_button_edit').click();
-
- // 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(turtleOnchange, 2, "should trigger one onchange per line updated");
- assert.strictEqual(partnerOnchange, 1, "should trigger only one onchange on the parent");
-
- 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, [[4,2,false],[1,1,{"turtle_int":2}],[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(3);
-
- var ids = [];
- for (var i=10; i<50; i++) {
- var id = 10 + i;
- ids.push(id);
- this.data.turtle.records.push({
- id: id,
- turtle_int: 0,
- turtle_foo: "#" + id,
- });
- }
- ids.push(1, 2, 3);
- this.data.partner.records[0].turtles = ids;
- 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,
- });
-
- form.$('.o_pager_next').click();
- 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'}
- );
-
- assert.strictEqual(form.$('.o_data_cell').text(), "blurpkawablip", "should display to record in 'turtle_int' order");
-
- form.$buttons.find('.o_form_button_save').click();
- form.$('.o_pager_next').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('onchange with modifiers for embedded one2many on the second page', function (assert) {
- assert.expect(7);
-
- var data = this.data;
- var ids = [];
- for (var i=10; i<60; i++) {
- var id = 10 + i;
- ids.push(id);
- data.turtle.records.push({
- id: id,
- turtle_int: 0,
- turtle_foo: "#" + id,
- });
- }
- ids.push(1, 2, 3);
- data.partner.records[0].turtles = ids;
- data.partner.onchanges = {
- turtles: function (obj) {
- // TODO: make this test more 'difficult'
- // For now, the server only returns UPDATE commands (no LINK TO)
- // even though it should do it (for performance reasons)
- // var turtles = obj.turtles.splice(0, 20);
-
- var turtles = [];
- turtles.unshift([5]);
- // create UPDATE commands for each records (this is the server
- // usual answer for onchange)
- for (var k in obj.turtles) {
- var change = obj.turtles[k];
- var record = _.findWhere(data.turtle.records, {id: change[1]});
- if (change[0] === 1) {
- _.extend(record, change[2]);
- }
- turtles.push([1, record.id, record]);
- }
- obj.turtles = turtles;
- },
- };
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: data,
- arch:'<form string="Partners">' +
- '<sheet>' +
- '<group>' +
- '<field name="turtles">' +
- '<tree editable="bottom" default_order="turtle_int" limit="10">' +
- '<field name="turtle_int" widget="handle"/>' +
- '<field name="turtle_foo"/>' +
- '<field name="turtle_qux" attrs="{\'readonly\': [(\'turtle_foo\', \'=\', False)]}"/>' +
- '</tree>' +
- '</field>' +
- '</group>' +
- '</sheet>' +
- '</form>',
- res_id: 1,
- });
-
- form.$buttons.find('.o_form_button_edit').click();
-
- assert.equal(form.$('.o_field_one2many td[class="o_data_cell"]').text(), "#20#21#22#23#24#25#26#27#28#29",
- "should display the records in order");
-
- 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');
-
- // the domain fail if the widget does not use the allready loaded data.
- form.$buttons.find('.o_form_button_cancel').click();
-
- assert.equal(form.$('.o_field_one2many td[class="o_data_cell"]').text(), "blurp#21#22#23#24#25#26#27#28#29",
- "should display the records in order with the changes");
-
- $('.modal .modal-footer button:first').click();
-
- assert.equal(form.$('.o_field_one2many td[class="o_data_cell"]').text(), "#20#21#22#23#24#25#26#27#28#29",
- "should cancel changes and display the records in order");
-
- form.$buttons.find('.o_form_button_edit').click();
-
- // 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'}
- );
-
- assert.equal(form.$('.o_field_one2many td[class="o_data_cell"]').text(), "#20#30#31#32#33#34#35#36#37#38",
- "should display the records in order after resequence (display record with turtle_int=0)");
-
- // 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'}
- );
-
- assert.equal(form.$('.o_field_one2many td[class="o_data_cell"]').text(), "#20#39#40#41#42#43#44#45#46#47",
- "should display the records in order after resequence (display record with turtle_int=0)");
-
- form.$buttons.find('.o_form_button_cancel').click();
-
- assert.equal(form.$('.o_field_one2many td[class="o_data_cell"]').text(), "#20#39#40#41#42#43#44#45#46#47",
- "should display the records in order after resequence");
-
- $('.modal .modal-footer button:first').click();
-
- assert.equal(form.$('.o_field_one2many td[class="o_data_cell"]').text(), "#20#21#22#23#24#25#26#27#28#29",
- "should cancel changes and display the records in order");
-
- form.destroy();
- });
-
- QUnit.test('onchange followed by edition on the second page', function (assert) {
- assert.expect(12);
-
- var ids = [];
- for (var i=1; i<85; i++) {
- var id = 10 + i;
- ids.push(id);
- this.data.turtle.records.push({
- id: id,
- turtle_int: id/3|0,
- turtle_foo: "#" + i,
- });
- }
- ids.splice(41, 0, 1, 2, 3);
- this.data.partner.records[0].turtles = ids;
- 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="top" default_order="turtle_int">' +
- '<field name="turtle_int" widget="handle"/>' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '</field>' +
- '</group>' +
- '</sheet>' +
- '</form>',
- res_id: 1,
- });
-
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_pager_next').click();
-
- form.$('.o_field_one2many .o_list_view tbody tr:eq(1) td:first').click();
- form.$('.o_field_one2many .o_list_view tbody tr:eq(1) input:first').val('value 1').trigger('input');
- form.$('.o_field_one2many .o_list_view tbody tr:eq(2) td:first').click();
- form.$('.o_field_one2many .o_list_view tbody tr:eq(2) input:first').val('value 2').trigger('input');
-
- assert.strictEqual(form.$('.o_data_row').length, 40, "should display 40 records");
- assert.strictEqual(form.$('.o_data_row:has(.o_data_cell:contains(#39))').index(), 0, "should display '#39' at the first line");
-
- form.$('.o_field_x2many_list_row_add a').click();
-
- assert.strictEqual(form.$('.o_data_row').length, 40, "should display 39 records and the create line");
- assert.strictEqual(form.$('.o_data_row:first .o_field_char').length, 1, "should display the create line in first position");
- assert.strictEqual(form.$('.o_data_row:first .o_field_char').val(), "", "should an empty input");
- assert.strictEqual(form.$('.o_data_row:has(.o_data_cell:contains(#39))').index(), 1, "should display '#39' at the second line");
-
- form.$('.o_data_row input:first').val('value 3').trigger('input');
-
- assert.strictEqual(form.$('.o_data_row:first .o_field_char').length, 1, "should display the create line in first position after onchange");
- assert.strictEqual(form.$('.o_data_row:has(.o_data_cell:contains(#39))').index(), 1, "should display '#39' at the second line after onchange");
-
- form.$('.o_field_x2many_list_row_add a').click();
-
- assert.strictEqual(form.$('.o_data_row').length, 40, "should display 39 records and the create line");
- assert.strictEqual(form.$('.o_data_row:first .o_field_char').length, 1, "should display the create line in first position");
- assert.strictEqual(form.$('.o_data_row:has(.o_data_cell:contains(value 3))').index(), 1, "should display the created line at the second position");
- assert.strictEqual(form.$('.o_data_row:has(.o_data_cell:contains(#39))').index(), 2, "should display '#39' at the third line");
-
- form.destroy();
- });
-
- QUnit.test('onchange followed by edition on the second page (part 2)', function (assert) {
- assert.expect(8);
-
- var ids = [];
- for (var i=1; i<85; i++) {
- var id = 10 + i;
- ids.push(id);
- this.data.turtle.records.push({
- id: id,
- turtle_int: id/3|0,
- turtle_foo: "#" + i,
- });
- }
- ids.splice(41, 0, 1, 2, 3);
- this.data.partner.records[0].turtles = ids;
- this.data.partner.onchanges = {
- turtles: function (obj) {
- obj.turtles = [[5]].concat(obj.turtles);
- },
- };
-
- // bottom order
-
- 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,
- });
-
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_pager_next').click();
-
- form.$('.o_field_one2many .o_list_view tbody tr:eq(1) td:first').click();
- form.$('.o_field_one2many .o_list_view tbody tr:eq(1) input:first').val('value 1').trigger('input');
- form.$('.o_field_one2many .o_list_view tbody tr:eq(2) td:first').click();
- form.$('.o_field_one2many .o_list_view tbody tr:eq(2) input:first').val('value 2').trigger('input');
-
- assert.strictEqual(form.$('.o_data_row').length, 40, "should display 40 records");
- assert.strictEqual(form.$('.o_data_row:has(.o_data_cell:contains(#77))').index(), 39, "should display '#77' at the last line");
-
- form.$('.o_field_x2many_list_row_add a').click();
-
- assert.strictEqual(form.$('.o_data_row').length, 41, "should display 41 records and the create line");
- assert.strictEqual(form.$('.o_data_row:has(.o_data_cell:contains(#76))').index(), 38, "should display '#76' at the penultimate line");
- assert.strictEqual(form.$('.o_data_row:has(.o_field_char)').index(), 40, "should display the create line at the last position");
-
- form.$('.o_data_row input:first').val('value 3').trigger('input');
- form.$('.o_field_x2many_list_row_add a').click();
-
- assert.strictEqual(form.$('.o_data_row').length, 42, "should display 42 records and the create line");
- assert.strictEqual(form.$('.o_data_row:has(.o_data_cell:contains(#76))').index(), 38, "should display '#76' at the penultimate line");
- assert.strictEqual(form.$('.o_data_row:has(.o_field_char)').index(), 41, "should display the create line at the last position");
-
- form.destroy();
- });
-
- QUnit.test('onchange returning a command 6 for an x2many', function (assert) {
- assert.expect(2);
-
- this.data.partner.onchanges = {
- foo: function (obj) {
- obj.turtles = [[6, false, [1, 2, 3]]];
- },
- };
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form>' +
- '<field name="foo"/>' +
- '<field name="turtles">' +
- '<tree>' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- viewOptions: {
- mode: 'edit',
- },
- });
-
- assert.strictEqual(form.$('.o_data_row').length, 1,
- "there should be one record in the relation");
-
- // change the value of foo to trigger the onchange
- form.$('.o_field_widget[name=foo]').val('some value').trigger('input');
-
- assert.strictEqual(form.$('.o_data_row').length, 3,
- "there should be three records in the relation");
-
- form.destroy();
- });
-
- QUnit.test('x2many fields inside x2manys are fetched after an onchange', function (assert) {
- assert.expect(6);
-
- this.data.turtle.records[0].partner_ids = [1];
- this.data.partner.onchanges = {
- foo: function (obj) {
- obj.turtles = [[5], [4, 1], [4, 2], [4, 3]];
- },
- };
-
- var checkRPC = false;
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form>' +
- '<sheet>' +
- '<group>' +
- '<field name="foo"/>' +
- '<field name="turtles">' +
- '<tree>' +
- '<field name="turtle_foo"/>' +
- '<field name="partner_ids" widget="many2many_tags"/>' +
- '</tree>' +
- '</field>' +
- '</group>' +
- '</sheet>' +
- '</form>',
- mockRPC: function (route, args) {
- if (checkRPC && args.method === 'read' && args.model === 'partner') {
- assert.deepEqual(args.args[1], ['display_name'],
- "should only read the display_name for the m2m tags");
- assert.deepEqual(args.args[0], [1],
- "should only read the display_name of the unknown record");
- }
- return this._super.apply(this, arguments);
- },
- res_id: 1,
- viewOptions: {
- mode: 'edit',
- },
- });
-
- assert.strictEqual(form.$('.o_data_row').length, 1,
- "there should be one record in the relation");
- assert.strictEqual(form.$('.o_data_row .o_field_widget[name=partner_ids]').text().replace(/\s/g, ''),
- 'secondrecordaaa', "many2many_tags should be correctly displayed");
-
- // change the value of foo to trigger the onchange
- checkRPC = true; // enable flag to check read RPC for the m2m field
- form.$('.o_field_widget[name=foo]').val('some value').trigger('input');
-
- assert.strictEqual(form.$('.o_data_row').length, 3,
- "there should be three records in the relation");
- assert.strictEqual(form.$('.o_data_row:first .o_field_widget[name=partner_ids]').text().trim(),
- 'first record', "many2many_tags should be correctly displayed");
-
- form.destroy();
- });
-
- QUnit.test('reference fields inside x2manys are fetched after an onchange', function (assert) {
- assert.expect(5);
-
- this.data.turtle.records[1].turtle_ref = 'product,41';
- this.data.partner.onchanges = {
- foo: function (obj) {
- obj.turtles = [[5], [4, 1], [4, 2], [4, 3]];
- },
- };
-
- var checkRPC = false;
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form>' +
- '<sheet>' +
- '<group>' +
- '<field name="foo"/>' +
- '<field name="turtles">' +
- '<tree>' +
- '<field name="turtle_foo"/>' +
- '<field name="turtle_ref" class="ref_field"/>' +
- '</tree>' +
- '</field>' +
- '</group>' +
- '</sheet>' +
- '</form>',
- mockRPC: function (route, args) {
- if (checkRPC && args.method === 'name_get') {
- assert.deepEqual(args.args[0], [37],
- "should only fetch the name_get of the unknown record");
- }
- return this._super.apply(this, arguments);
- },
- res_id: 1,
- viewOptions: {
- mode: 'edit',
- },
- });
-
- assert.strictEqual(form.$('.o_data_row').length, 1,
- "there should be one record in the relation");
- assert.strictEqual(form.$('.ref_field').text().trim(), 'xpad',
- "reference field should be correctly displayed");
-
- // change the value of foo to trigger the onchange
- checkRPC = true; // enable flag to check read RPC for reference field
- form.$('.o_field_widget[name=foo]').val('some value').trigger('input');
-
- assert.strictEqual(form.$('.o_data_row').length, 3,
- "there should be three records in the relation");
- assert.strictEqual(form.$('.ref_field').text().trim(), 'xpadxphone',
- "reference fields should be correctly displayed");
-
- form.destroy();
- });
-
- QUnit.test('onchange on one2many containing x2many in form view', function (assert) {
- assert.expect(16);
-
- this.data.partner.onchanges = {
- foo: function (obj) {
- obj.turtles = [[0, false, {turtle_foo: 'new record'}]];
- },
- };
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form>' +
- '<field name="foo"/>' +
- '<field name="turtles">' +
- '<tree>' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '<form>' +
- '<field name="partner_ids">' +
- '<tree editable="top">' +
- '<field name="foo"/>' +
- '</tree>' +
- '</field>' +
- '</form>' +
- '</field>' +
- '</form>',
- archs: {
- 'partner,false,list': '<tree><field name="foo"/></tree>',
- 'partner,false,search': '<search></search>',
- },
- });
-
- assert.strictEqual(form.$('.o_data_row').length, 1,
- "the onchange should have created one record in the relation");
-
- // open the created o2m record in a form view, and add a m2m subrecord
- // in its relation
- form.$('.o_data_row').click();
-
- assert.strictEqual($('.modal').length, 1, "should have opened a dialog");
- assert.strictEqual($('.modal .o_data_row').length, 0,
- "there should be no record in the one2many in the dialog");
-
- // add a many2many subrecord
- $('.modal .o_field_x2many_list_row_add a').click();
-
- assert.strictEqual($('.modal').length, 2,
- "should have opened a second dialog");
-
- // select a many2many subrecord
- $('.modal:nth(1) .o_list_view .o_data_cell:first').click();
-
- assert.strictEqual($('.modal').length, 1,
- "second dialog should be closed");
- assert.strictEqual($('.modal .o_data_row').length, 1,
- "there should be one record in the one2many in the dialog");
- assert.notOk($('.modal .o_x2m_control_panel .o_cp_pager div').is(':visible'),
- 'm2m pager should be hidden');
-
- // click on 'Save & Close'
- $('.modal-footer .btn-primary:first').click();
-
- assert.strictEqual($('.modal').length, 0, "dialog should be closed");
-
- // reopen o2m record, and another m2m subrecord in its relation, but
- // discard the changes
- form.$('.o_data_row').click();
-
- assert.strictEqual($('.modal').length, 1, "should have opened a dialog");
- assert.strictEqual($('.modal .o_data_row').length, 1,
- "there should be one record in the one2many in the dialog");
-
- // add another m2m subrecord
- $('.modal .o_field_x2many_list_row_add a').click();
-
- assert.strictEqual($('.modal').length, 2,
- "should have opened a second dialog");
-
- $('.modal:nth(1) .o_list_view .o_data_cell:first').click();
-
- assert.strictEqual($('.modal').length, 1,
- "second dialog should be closed");
- assert.strictEqual($('.modal .o_data_row').length, 2,
- "there should be two records in the one2many in the dialog");
-
- // click on 'Discard'
- $('.modal-footer .btn-secondary').click();
-
- assert.strictEqual($('.modal').length, 0, "dialog should be closed");
-
- // reopen o2m record to check that second changes have properly been discarded
- form.$('.o_data_row').click();
-
- assert.strictEqual($('.modal').length, 1, "should have opened a dialog");
- assert.strictEqual($('.modal .o_data_row').length, 1,
- "there should be one record in the one2many in the dialog");
-
- form.destroy();
- });
-
- QUnit.test('embedded one2many with handle widget with minimum setValue calls', function (assert) {
- var done = assert.async();
- assert.expect(20);
-
-
- this.data.turtle.records[0].turtle_int = 6;
- this.data.turtle.records.push({
- id: 4,
- turtle_int: 20,
- turtle_foo: "a1",
- }, {
- id: 5,
- turtle_int: 9,
- turtle_foo: "a2",
- }, {
- id: 6,
- turtle_int: 2,
- turtle_foo: "a3",
- }, {
- id: 7,
- turtle_int: 11,
- turtle_foo: "a4",
- });
- this.data.partner.records[0].turtles = [1, 2, 3, 4, 5, 6, 7];
-
- 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,
- });
-
- testUtils.intercept(form, "field_changed", function (event) {
- assert.step(form.model.get(event.data.changes.turtles.id).res_id);
- }, true);
-
- form.$buttons.find('.o_form_button_edit').click();
-
- var steps = [];
- var positions = [
- [6, 0, 'top', [3, 6, 1, 2, 5, 7, 4]], // move the last to the first line
- [5, 1, 'top', [7, 6, 1, 2, 5]], // move the penultimate to the second line
- [2, 5, 'center', [1, 2, 5, 6]], // move the third to the penultimate line
- ];
- function dragAndDrop() {
- var pos = positions.shift();
-
- testUtils.dragAndDrop(
- form.$('.ui-sortable-handle').eq(pos[0]),
- form.$('tbody tr').eq(pos[1]),
- {position: pos[2]}
- );
-
- steps = steps.concat(pos[3]);
- assert.verifySteps(steps,
- "sequences values should be apply from the begin index to the drop index");
-
- if (positions.length) {
-
- setTimeout(dragAndDrop, 10);
-
- } else {
-
- assert.deepEqual(_.pluck(form.model.get(form.handle).data.turtles.data, 'data'), [
- { id: 3, turtle_foo: "kawa", turtle_int: 2 },
- { id: 7, turtle_foo: "a4", turtle_int: 3 },
- { id: 1, turtle_foo: "yop", turtle_int: 4 },
- { id: 2, turtle_foo: "blip", turtle_int: 5 },
- { id: 5, turtle_foo: "a2", turtle_int: 6 },
- { id: 6, turtle_foo: "a3", turtle_int: 7 },
- { id: 4, turtle_foo: "a1", turtle_int: 8 }
- ], "sequences must be apply correctly");
-
- form.destroy();
- done();
- }
- }
-
- dragAndDrop();
- });
-
- QUnit.test('embedded one2many (editable list) with handle widget', function (assert) {
- assert.expect(8);
-
- this.data.partner.records[0].p = [1, 2, 4];
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<sheet>' +
- '<notebook>' +
- '<page string="P page">' +
- '<field name="p">' +
- '<tree editable="top">' +
- '<field name="int_field" widget="handle"/>' +
- '<field name="foo"/>' +
- '</tree>' +
- '</field>' +
- '</page>' +
- '</notebook>' +
- '</sheet>' +
- '</form>',
- res_id: 1,
- });
-
- testUtils.intercept(form, "field_changed", function (event) {
- assert.step(event.data.changes.p.data.int_field.toString());
- }, true);
-
- assert.strictEqual(form.$('td.o_data_cell:not(.o_handle_cell)').text(), "My little Foo Valueblipyop",
- "should have the 3 rows in the correct order");
-
- form.$buttons.find('.o_form_button_edit').click();
- assert.strictEqual(form.$('td.o_data_cell:not(.o_handle_cell)').text(), "My little Foo Valueblipyop",
- "should still 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.verifySteps(["0", "1"],
- "sequences values should be incremental starting from the previous minimum one");
-
- assert.strictEqual(form.$('td.o_data_cell:not(.o_handle_cell)').text(), "blipMy little Foo Valueyop",
- "should have the 3 rows in the new order");
-
- form.$('tbody tr:first td:first').click();
-
- assert.strictEqual(form.$('tbody tr:first td.o_data_cell:not(.o_handle_cell) input').val(), "blip",
- "should edit the correct row");
-
- form.$buttons.find('.o_form_button_save').click();
- assert.strictEqual(form.$('td.o_data_cell:not(.o_handle_cell)').text(), "blipMy little Foo Valueyop",
- "should still have the 3 rows in the new order");
-
- form.destroy();
- });
-
- QUnit.test('one2many field when using the pager', function (assert) {
- assert.expect(13);
-
- var ids = [];
- for (var i=0; i<45; i++) {
- var id = 10 + i;
- ids.push(id);
- this.data.partner.records.push({
- id: id,
- display_name: "relational record " + id,
- });
- }
- this.data.partner.records[0].p = ids.slice(0, 42);
- this.data.partner.records[1].p = ids.slice(42);
-
- var count = 0;
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="p">' +
- '<kanban>' +
- '<field name="display_name"/>' +
- '<templates>' +
- '<t t-name="kanban-box">' +
- '<div><t t-esc="record.display_name"/></div>' +
- '</t>' +
- '</templates>' +
- '</kanban>' +
- '</field>' +
- '</form>',
- viewOptions: {
- ids: [1, 2],
- index: 0,
- },
- mockRPC: function () {
- count++;
- return this._super.apply(this, arguments);
- },
- res_id: 1,
- });
-
- // we are on record 1, which has 90 related record (first 40 should be
- // displayed), 2 RPCs (read) should have been done, one on the main record
- // and one for the O2M
- assert.strictEqual(count, 2, 'two RPCs should have been done');
- assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40,
- 'one2many kanban should contain 40 cards for record 1');
-
- // move to record 2, which has 3 related records (and shouldn't contain the
- // related records of record 1 anymore). Two additional RPCs should have
- // been done
- form.pager.next();
- assert.strictEqual(count, 4, 'two RPCs should have been done');
- assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 3,
- 'one2many kanban should contain 3 cards for record 2');
-
- // move back to record 1, which should contain again its first 40 related
- // records
- form.pager.previous();
- assert.strictEqual(count, 6, 'two RPCs should have been done');
- assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40,
- 'one2many kanban should contain 40 cards for record 1');
-
- // move to the second page of the o2m: 1 RPC should have been done to fetch
- // the 2 subrecords of page 2, and those records should now be displayed
- form.$('.o_x2m_control_panel .o_pager_next').click();
- assert.strictEqual(count, 7, 'one RPC should have been done');
- assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 2,
- 'one2many kanban should contain 2 cards for record 1 at page 2');
-
- // move to record 2 again and check that everything is correctly updated
- form.pager.next();
- assert.strictEqual(count, 9, 'two RPCs should have been done');
- assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 3,
- 'one2many kanban should contain 3 cards for record 2');
-
- // move back to record 1 and move to page 2 again: all data should have
- // been correctly reloaded
- form.pager.previous();
- assert.strictEqual(count, 11, 'two RPCs should have been done');
- form.$('.o_x2m_control_panel .o_pager_next').click();
- assert.strictEqual(count, 12, 'one RPC should have been done');
- assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 2,
- 'one2many kanban should contain 2 cards for record 1 at page 2');
- form.destroy();
- });
-
- QUnit.test('edition of one2many field with pager', function (assert) {
- assert.expect(31);
-
- var ids = [];
- for (var i = 0; i < 45; i++) {
- var id = 10 + i;
- ids.push(id);
- this.data.partner.records.push({
- id: id,
- display_name: "relational record " + id,
- });
- }
- this.data.partner.records[0].p = ids;
-
- var saveCount = 0;
- var checkRead = false;
- var readIDs;
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="p">' +
- '<kanban>' +
- '<field name="display_name"/>' +
- '<templates>' +
- '<t t-name="kanban-box">' +
- '<div class="oe_kanban_global_click">' +
- '<a t-if="!read_only_mode" type="delete" class="fa fa-times float-right delete_icon"/>' +
- '<span><t t-esc="record.display_name.value"/></span>' +
- '</div>' +
- '</t>' +
- '</templates>' +
- '</kanban>' +
- '</field>' +
- '</form>',
- archs: {
- 'partner,false,form': '<form><field name="display_name"/></form>',
- },
- mockRPC: function (route, args) {
- if (args.method === 'read' && checkRead) {
- readIDs = args.args[0];
- checkRead = false;
- }
- if (args.method === 'write') {
- saveCount++;
- var nbCommands = args.args[1].p.length;
- var nbLinkCommands = _.filter(args.args[1].p, function (command) {
- return command[0] === 4;
- }).length;
- switch(saveCount) {
- case 1:
- assert.strictEqual(nbCommands, 46,
- "should send 46 commands (one for each record)");
- assert.strictEqual(nbLinkCommands, 45,
- "should send a LINK_TO command for each existing record");
- assert.deepEqual(args.args[1].p[45], [0, args.args[1].p[45][1], {
- display_name: 'new record',
- }], "should sent a CREATE command for the new record");
- break;
- case 2:
- assert.strictEqual(nbCommands, 46,
- "should send 46 commands");
- assert.strictEqual(nbLinkCommands, 45,
- "should send a LINK_TO command for each existing record");
- assert.deepEqual(args.args[1].p[45], [2, 10, false],
- "should sent a DELETE command for the deleted record");
- break;
- case 3:
- assert.strictEqual(nbCommands, 47,
- "should send 47 commands");
- assert.strictEqual(nbLinkCommands, 43,
- "should send a LINK_TO command for each existing record");
- assert.deepEqual(args.args[1].p[43],
- [0, args.args[1].p[43][1], {display_name: 'new record page 1'}],
- "should sent correct CREATE command");
- assert.deepEqual(args.args[1].p[44],
- [0, args.args[1].p[44][1], {display_name: 'new record page 2'}],
- "should sent correct CREATE command");
- assert.deepEqual(args.args[1].p[45],
- [2, 11, false],
- "should sent correct DELETE command");
- assert.deepEqual(args.args[1].p[46],
- [2, 52, false],
- "should sent correct DELETE command");
- break;
- }
- }
- return this._super.apply(this, arguments);
- },
- res_id: 1,
- });
-
- assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40,
- 'there should be 40 records on page 1');
- assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(),
- '1-40 / 45', "pager range should be correct");
-
- // add a record on page one
- checkRead = true;
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o-kanban-button-new').click();
- $('.modal input').val('new record').trigger('input');
- $('.modal .modal-footer .btn-primary:first').click(); // save and close
- // checks
- assert.strictEqual(readIDs, undefined, "should not have read any record");
- assert.strictEqual(form.$('span:contains(new record)').length, 0,
- "new record should be on page 2");
- assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40,
- 'there should be 40 records on page 1');
- assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(),
- '1-40 / 46', "pager range should be correct");
- assert.strictEqual(form.$('.o_kanban_record:first span:contains(new record)').length,
- 0, 'new record should not be on page 1');
- // save
- form.$buttons.find('.o_form_button_save').click();
-
- // delete a record on page one
- checkRead = true;
- form.$buttons.find('.o_form_button_edit').click();
- assert.strictEqual(form.$('.o_kanban_record:first span:contains(relational record 10)').length,
- 1, 'first record should be the one with id 10 (next checks rely on that)');
- form.$('.delete_icon:first').click();
- // checks
- assert.deepEqual(readIDs, [50],
- "should have read a record (to display 40 records on page 1)");
- assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40,
- 'there should be 40 records on page 1');
- assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(),
- '1-40 / 45', "pager range should be correct");
- // save
- form.$buttons.find('.o_form_button_save').click();
-
- // add and delete records in both pages
- form.$buttons.find('.o_form_button_edit').click();
- checkRead = true;
- readIDs = undefined;
- // add and delete a record in page 1
- form.$('.o-kanban-button-new').click();
- $('.modal input').val('new record page 1').trigger('input');
- $('.modal .modal-footer .btn-primary:first').click(); // save and close
- assert.strictEqual(form.$('.o_kanban_record:first span:contains(relational record 11)').length,
- 1, 'first record should be the one with id 11 (next checks rely on that)');
- form.$('.delete_icon:first').click();
- assert.deepEqual(readIDs, [51],
- "should have read a record (to display 40 records on page 1)");
- // add and delete a record in page 2
- form.$('.o_x2m_control_panel .o_pager_next').click();
- assert.strictEqual(form.$('.o_kanban_record:first span:contains(relational record 52)').length,
- 1, 'first record should be the one with id 52 (next checks rely on that)');
- checkRead = true;
- readIDs = undefined;
- form.$('.delete_icon:first').click();
- form.$('.o-kanban-button-new').click();
- $('.modal input').val('new record page 2').trigger('input');
- $('.modal .modal-footer .btn-primary:first').click(); // save and close
- assert.strictEqual(readIDs, undefined, "should not have read any record");
- // checks
- assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 5,
- 'there should be 5 records on page 2');
- assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(),
- '41-45 / 45', "pager range should be correct");
- assert.strictEqual(form.$('.o_kanban_record span:contains(new record page 1)').length,
- 1, 'new records should be on page 2');
- assert.strictEqual(form.$('.o_kanban_record span:contains(new record page 2)').length,
- 1, 'new records should be on page 2');
- // save
- form.$buttons.find('.o_form_button_save').click();
-
- form.destroy();
- });
-
- QUnit.test('edition of one2many field, with onchange and not inline sub view', function (assert) {
- assert.expect(2);
-
- this.data.turtle.onchanges.turtle_int = function (obj) {
- obj.turtle_foo = String(obj.turtle_int);
- };
- this.data.partner.onchanges.turtles = function () {};
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="turtles"/>' +
- '</form>',
- archs: {
- 'turtle,false,list': '<tree><field name="turtle_foo"/></tree>',
- 'turtle,false,form': '<form><group><field name="turtle_foo"/><field name="turtle_int"/></group></form>',
- },
- mockRPC: function (route, args) {
- return this._super.apply(this, arguments);
- },
- res_id: 1,
- });
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_field_x2many_list_row_add a').click();
- $('input[name="turtle_int"]').val('5').trigger('input');
- $('.modal-footer button.btn-primary').first().click();
- assert.strictEqual(form.$('tbody tr:eq(1) td.o_data_cell').text(), '5',
- 'should display 5 in the foo field');
- form.$('tbody tr:eq(1) td.o_data_cell').click();
-
- $('input[name="turtle_int"]').val('3').trigger('input');
- $('.modal-footer button.btn-primary').first().click();
- assert.strictEqual(form.$('tbody tr:eq(1) td.o_data_cell').text(), '3',
- 'should now display 3 in the foo field');
- form.destroy();
- });
-
- QUnit.test('sorting one2many fields', function (assert) {
- assert.expect(4);
-
- this.data.partner.fields.foo.sortable = true;
- this.data.partner.records.push({id: 23, foo: "abc"});
- this.data.partner.records.push({id: 24, foo: "xyz"});
- this.data.partner.records.push({id: 25, foo: "def"});
- this.data.partner.records[0].p = [23,24,25];
-
- var rpcCount = 0;
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="p">' +
- '<tree>' +
- '<field name="foo"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- mockRPC: function () {
- rpcCount++;
- return this._super.apply(this, arguments);
- },
- });
-
- rpcCount = 0;
- assert.ok(form.$('table tbody tr:eq(2) td:contains(def)').length,
- "the 3rd record is the one with 'def' value");
- form.renderer._render = function () {
- throw "should not render the whole form";
- };
-
- form.$('table thead th:contains(Foo)').click();
- assert.strictEqual(rpcCount, 0,
- 'sort should be in memory, no extra RPCs should have been done');
- assert.ok(form.$('table tbody tr:eq(2) td:contains(xyz)').length,
- "the 3rd record is the one with 'xyz' value");
-
- form.$('table thead th:contains(Foo)').click();
- assert.ok(form.$('table tbody tr:eq(2) td:contains(abc)').length,
- "the 3rd record is the one with 'abc' value");
-
- form.destroy();
- });
-
- QUnit.test('one2many list field edition', function (assert) {
- assert.expect(6);
-
- this.data.partner.records.push({
- id: 3,
- display_name: "relational record 1",
- });
- this.data.partner.records[1].p = [3];
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="p">' +
- '<tree editable="top">' +
- '<field name="display_name"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 2,
- });
-
- // edit the first line of the o2m
- assert.strictEqual(form.$('.o_field_one2many tbody td').first().text(), 'relational record 1',
- "display name of first record in o2m list should be 'relational record 1'");
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_field_one2many tbody td').first().click();
- assert.ok(form.$('.o_field_one2many tbody td').first().parent().hasClass('o_selected_row'),
- "first row of o2m should be in edition");
- form.$('.o_field_one2many tbody td').first().find('input').val("new value").trigger('input');
- assert.ok(form.$('.o_field_one2many tbody td').first().parent().hasClass('o_selected_row'),
- "first row of o2m should still be in edition");
-
- // // leave o2m edition
- form.$el.click();
- assert.ok(!form.$('.o_field_one2many tbody td').first().parent().hasClass('o_selected_row'),
- "first row of o2m should be readonly again");
-
- // discard changes
- form.$buttons.find('.o_form_button_cancel').click();
- assert.strictEqual(form.$('.o_field_one2many tbody td').first().text(), 'new value',
- "changes shouldn't have been discarded yet, waiting for user confirmation");
- $('.modal .modal-footer .btn-primary').click();
- assert.strictEqual(form.$('.o_field_one2many tbody td').first().text(), 'relational record 1',
- "display name of first record in o2m list should be 'relational record 1'");
-
- // edit again and save
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_field_one2many tbody td').first().click();
- form.$('.o_field_one2many tbody td').first().find('input').val("new value").trigger('input');
- form.$el.click();
- form.$buttons.find('.o_form_button_save').click();
- // FIXME: this next test doesn't pass as the save of updates of
- // relational data is temporarily disabled
- // assert.strictEqual(form.$('.o_field_one2many tbody td').first().text(), 'new value',
- // "display name of first record in o2m list should be 'new value'");
-
- form.destroy();
- });
-
- QUnit.test('one2many list: create action disabled', function (assert) {
- assert.expect(2);
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="p">' +
- '<tree create="0">' +
- '<field name="display_name"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- });
-
- assert.ok(!form.$('.o_field_x2many_list_row_add').length,
- '"Add an item" link should not be available in readonly');
-
- form.$buttons.find('.o_form_button_edit').click();
-
- assert.ok(!form.$('.o_field_x2many_list_row_add').length,
- '"Add an item" link should not be available in readonly');
- form.destroy();
- });
-
- QUnit.test('one2many list: unlink two records', function (assert) {
- assert.expect(9);
- this.data.partner.records[0].p = [1, 2, 4];
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="p" widget="many2many">' +
- '<tree>' +
- '<field name="display_name"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- mockRPC: function (route, args) {
- if (route === '/web/dataset/call_kw/partner/write') {
- var commands = args.args[1].p;
- assert.strictEqual(commands.length, 3,
- 'should have generated three commands');
- assert.ok(commands[0][0] === 4 && commands[0][1] === 4,
- 'should have generated the command 4 (LINK_TO) with id 4');
- assert.ok(commands[1][0] === 3 && commands[1][1] === 1,
- 'should have generated the command 3 (UNLINK) with id 1');
- assert.ok(commands[2][0] === 3 && commands[2][1] === 2,
- 'should have generated the command 3 (UNLINK) with id 2');
- }
- return this._super.apply(this, arguments);
- },
- archs: {
- 'partner,false,form':
- '<form string="Partner"><field name="display_name"/></form>',
- },
- });
- form.$buttons.find('.o_form_button_edit').click();
-
- assert.strictEqual(form.$('td.o_list_record_remove button').length, 3,
- "should have 3 remove buttons");
-
- assert.ok(form.$('td.o_list_record_remove button').first().hasClass('fa fa-times'),
- "should have X icons to remove (unlink) records");
-
- form.$('td.o_list_record_remove button').first().click();
-
- assert.strictEqual(form.$('td.o_list_record_remove button').length, 2,
- "should have 2 remove buttons (a record is supposed to have been unlinked)");
-
- form.$('tr.o_data_row').first().click();
- assert.strictEqual($('.modal .modal-footer .o_btn_remove').length, 1,
- 'there should be a modal having Remove Button');
- $('.modal .modal-footer .o_btn_remove').click();
-
- assert.strictEqual(form.$('td.o_list_record_remove button').length, 1,
- "should have 1 delete button (another record is supposed to have been unlinked)");
-
- // save and check that the correct command has been generated
- form.$buttons.find('.o_form_button_save').click();
- form.destroy();
- });
-
- QUnit.test('one2many list: deleting two records', function (assert) {
- assert.expect(9);
- this.data.partner.records[0].p = [1, 2, 4];
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="p">' +
- '<tree>' +
- '<field name="display_name"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- mockRPC: function (route, args) {
- if (route === '/web/dataset/call_kw/partner/write') {
- var commands = args.args[1].p;
- assert.strictEqual(commands.length, 3,
- 'should have generated three commands');
- assert.ok(commands[0][0] === 4 && commands[0][1] === 4,
- 'should have generated the command 4 (LINK_TO) with id 4');
- assert.ok(commands[1][0] === 2 && commands[1][1] === 1,
- 'should have generated the command 2 (DELETE) with id 1');
- assert.ok(commands[2][0] === 2 && commands[2][1] === 2,
- 'should have generated the command 2 (DELETE) with id 2');
- }
- return this._super.apply(this, arguments);
- },
- archs: {
- 'partner,false,form':
- '<form string="Partner"><field name="display_name"/></form>',
- },
- });
- form.$buttons.find('.o_form_button_edit').click();
-
- assert.strictEqual(form.$('td.o_list_record_remove button').length, 3,
- "should have 3 remove buttons");
-
- assert.ok(form.$('td.o_list_record_remove button').first().hasClass('fa fa-trash-o'),
- "should have trash bin icons to remove (delete) records");
-
- form.$('td.o_list_record_remove button').first().click();
-
- assert.strictEqual(form.$('td.o_list_record_remove button').length, 2,
- "should have 2 remove buttons (a record is supposed to have been deleted)");
-
- form.$('tr.o_data_row').first().click();
- assert.strictEqual($('.modal .modal-footer .o_btn_remove').length, 1,
- 'there should be a modal having Remove Button');
- $('.modal .modal-footer .o_btn_remove').click();
-
- assert.strictEqual(form.$('td.o_list_record_remove button').length, 1,
- "should have 1 remove button (another record is supposed to have been deleted)");
-
- // save and check that the correct command has been generated
- form.$buttons.find('.o_form_button_save').click();
-
- // FIXME: it would be nice to test that the view is re-rendered correctly,
- // but as the relational data isn't re-fetched, the rendering is ok even
- // if the changes haven't been saved
- form.destroy();
- });
-
- QUnit.test('one2many kanban: edition', function (assert) {
- assert.expect(17);
-
- this.data.partner.records[0].p = [2];
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="p">' +
- '<kanban>' +
- // color will be in the kanban but not in the form
- '<field name="color"/>' +
- '<field name="display_name"/>' +
- '<templates>' +
- '<t t-name="kanban-box">' +
- '<div class="oe_kanban_global_click">' +
- '<a t-if="!read_only_mode" type="delete" class="fa fa-times float-right delete_icon"/>' +
- '<span><t t-esc="record.display_name.value"/></span>' +
- '<span><t t-esc="record.color.value"/></span>' +
- '</div>' +
- '</t>' +
- '</templates>' +
- '</kanban>' +
- '<form string="Partners">' +
- '<field name="display_name"/>' +
- // foo will be in the form but not in the kanban
- '<field name="foo"/>' +
- '</form>' +
- '</field>' +
- '</form>',
- res_id: 1,
- });
-
- assert.ok(!form.$('.o_kanban_view .delete_icon').length,
- 'delete icon should not be visible in readonly');
- assert.ok(!form.$('.o_field_one2many .o-kanban-button-new').length,
- '"Create" button should not be visible in readonly');
-
- form.$buttons.find('.o_form_button_edit').click();
-
- assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 1,
- 'should contain 1 record');
- assert.strictEqual(form.$('.o_kanban_record span:first').text(), 'second record',
- 'display_name of subrecord should be the one in DB');
- assert.strictEqual(form.$('.o_kanban_record span:nth(1)').text(), 'Red',
- 'color of subrecord should be the one in DB');
- assert.ok(form.$('.o_kanban_view .delete_icon').length,
- 'delete icon should be visible in edit');
- assert.ok(form.$('.o_field_one2many .o-kanban-button-new').length,
- '"Create" button should be visible in edit');
- assert.ok(form.$('.o_field_one2many .o-kanban-button-new').hasClass('btn-secondary'),
- "'Create' button should have className 'btn-secondary'");
- assert.strictEqual(form.$('.o_field_one2many .o-kanban-button-new').text().trim(), "Add",
- 'Create button should have "Add" label');
-
- // edit existing subrecord
- form.$('.oe_kanban_global_click').click();
-
- $('.modal .o_form_view input').val('new name').trigger('input');
- $('.modal .modal-footer .btn-primary').click(); // save
- assert.strictEqual(form.$('.o_kanban_record span:first').text(), 'new name',
- 'value of subrecord should have been updated');
-
- // create a new subrecord
- form.$('.o-kanban-button-new').click();
- $('.modal .o_form_view input').val('new subrecord 1').trigger('input');
- $('.modal .modal-footer .btn-primary').click(); // save and close
- assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 2,
- 'should contain 2 records');
- assert.strictEqual(form.$('.o_kanban_record:nth(1) span').text(), 'new subrecord 1',
- 'value of newly created subrecord should be "new subrecord 1"');
-
- // create two new subrecords
- form.$('.o-kanban-button-new').click();
- $('.modal .o_form_view input').val('new subrecord 2').trigger('input');
- $('.modal .modal-footer .btn-primary:nth(1)').click(); // save and new
- $('.modal .o_form_view input').val('new subrecord 3').trigger('input');
- $('.modal .modal-footer .btn-primary').click(); // save and close
- assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 4,
- 'should contain 4 records');
-
- // delete subrecords
- form.$('.oe_kanban_global_click').first().click();
- assert.strictEqual($('.modal .modal-footer .o_btn_remove').length, 1,
- 'There should be a modal having Remove Button');
- $('.modal .modal-footer .o_btn_remove').click(); // Click Remove Button of Formview Dialog
- assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 3,
- 'should contain 3 records');
- form.$('.o_kanban_view .delete_icon:first()').click();
- form.$('.o_kanban_view .delete_icon:first()').click();
- assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 1,
- 'should contain 1 records');
- assert.strictEqual(form.$('.o_kanban_record span:first').text(), 'new subrecord 3',
- 'the remaining subrecord should be "new subrecord 3"');
- form.destroy();
- });
-
- QUnit.test('one2many kanban (editable): properly handle create_text node option', function (assert) {
- assert.expect(1);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="turtles" options="{\'create_text\': \'Add turtle\'}" mode="kanban">' +
- '<kanban>' +
- '<templates>' +
- '<t t-name="kanban-box">' +
- '<div class="oe_kanban_details">' +
- '<field name="display_name"/>' +
- '</div>' +
- '</t>' +
- '</templates>' +
- '</kanban>' +
- '</field>' +
- '</form>',
- res_id: 1,
- });
-
- form.$buttons.find('.o_form_button_edit').click();
- assert.strictEqual(form.$('.o_field_one2many[name="turtles"] .o-kanban-button-new').text().trim(),
- "Add turtle", "In O2M Kanban, Add button should have 'Add turtle' label");
-
- form.destroy();
- });
-
- QUnit.test('one2many kanban: create action disabled', function (assert) {
- assert.expect(3);
-
- this.data.partner.records[0].p = [4];
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="p">' +
- '<kanban create="0">' +
- '<field name="display_name"/>' +
- '<templates>' +
- '<t t-name="kanban-box">' +
- '<div class="oe_kanban_global_click">' +
- '<a t-if="!read_only_mode" type="delete" class="fa fa-times float-right delete_icon"/>' +
- '<span><t t-esc="record.display_name.value"/></span>' +
- '</div>' +
- '</t>' +
- '</templates>' +
- '</kanban>' +
- '</field>' +
- '</form>',
- res_id: 1,
- });
-
- assert.ok(!form.$('.o-kanban-button-new').length,
- '"Add" button should not be available in readonly');
-
- form.$buttons.find('.o_form_button_edit').click();
-
- assert.ok(!form.$('.o-kanban-button-new').length,
- '"Add" button should not be available in edit');
- assert.ok(form.$('.o_kanban_view .delete_icon').length,
- 'delete icon should be visible in edit');
- form.destroy();
- });
-
- QUnit.test('editable one2many list, pager is updated', function (assert) {
- assert.expect(1);
-
- this.data.turtle.records.push({id:4, turtle_foo: 'stephen hawking'});
- this.data.partner.records[0].turtles = [1,2,3,4];
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="turtles">' +
- '<tree editable="bottom" limit="3">' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- });
-
- // add a record, then click in form view to confirm it
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_field_x2many_list_row_add a').click();
- form.$el.click();
-
- assert.strictEqual(form.$('.o_cp_pager').text().trim(), '1-3 / 5',
- "pager should display the correct total");
- form.destroy();
- });
-
- QUnit.test('one2many list (non editable): edition', function (assert) {
- assert.expect(12);
-
- var nbWrite = 0;
- this.data.partner.records[0].p = [2, 4];
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="p">' +
- '<tree>' +
- '<field name="display_name"/><field name="qux"/>' +
- '</tree>' +
- '<form string="Partners">' +
- '<field name="display_name"/>' +
- '</form>' +
- '</field>' +
- '</form>',
- res_id: 1,
- mockRPC: function (route, args) {
- if (args.method === 'write') {
- nbWrite++;
- assert.deepEqual(args.args[1], {
- p: [[1, 2, {display_name: 'new name'}], [2, 4, false]]
- }, "should have sent the correct commands");
- }
- return this._super.apply(this, arguments);
- },
- });
-
- assert.ok(!form.$('.o_list_record_remove').length,
- 'remove icon should not be visible in readonly');
- assert.ok(!form.$('.o_field_x2many_list_row_add').length,
- '"Add an item" should not be visible in readonly');
-
- form.$buttons.find('.o_form_button_edit').click();
-
- assert.strictEqual(form.$('.o_list_view td.o_list_number').length, 2,
- 'should contain 2 records');
- assert.strictEqual(form.$('.o_list_view tbody td:first()').text(), 'second record',
- 'display_name of first subrecord should be the one in DB');
- assert.ok(form.$('.o_list_record_remove').length,
- 'remove icon should be visible in edit');
- assert.ok(form.$('.o_field_x2many_list_row_add').length,
- '"Add an item" should not visible in edit');
-
- // edit existing subrecord
- form.$('.o_list_view tbody tr:first() td:eq(1)').click();
-
- $('.modal .o_form_view input').val('new name').trigger('input');
- $('.modal .modal-footer .btn-primary').click(); // save
- assert.strictEqual(form.$('.o_list_view tbody td:first()').text(), 'new name',
- 'value of subrecord should have been updated');
- assert.strictEqual(nbWrite, 0, "should not have write anything in DB");
-
- // create new subrecords
- // TODO when 'Add an item' will be implemented
-
- // remove subrecords
- form.$('.o_list_record_remove:nth(1)').click();
- assert.strictEqual(form.$('.o_list_view td.o_list_number').length, 1,
- 'should contain 1 subrecord');
- assert.strictEqual(form.$('.o_list_view tbody td:first()').text(), 'new name',
- 'the remaining subrecord should be "new name"');
-
- form.$buttons.find('.o_form_button_save').click(); // save the record
- assert.strictEqual(nbWrite, 1, "should have write the changes in DB");
-
- form.destroy();
- });
-
- QUnit.test('one2many list (editable): edition', function (assert) {
- assert.expect(7);
-
- this.data.partner.records[0].p = [2, 4];
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="p">' +
- '<tree editable="top">' +
- '<field name="display_name"/><field name="qux"/>' +
- '</tree>' +
- '<form string="Partners">' +
- '<field name="display_name"/>' +
- '</form>' +
- '</field>' +
- '</form>',
- res_id: 1,
- });
-
- assert.ok(!form.$('.o_field_x2many_list_row_add').length,
- '"Add an item" link should not be available in readonly');
-
- form.$('.o_list_view tbody td:first()').click();
- assert.ok($('.modal .o_form_readonly').length,
- 'in readonly, clicking on a subrecord should open it in readonly in a dialog');
- $('.modal .o_form_button_cancel').click(); // close the dialog
-
- form.$buttons.find('.o_form_button_edit').click();
-
- assert.ok(form.$('.o_field_x2many_list_row_add').length,
- '"Add an item" link should be available in edit');
-
- // edit existing subrecord
- form.$('.o_list_view tbody td:first()').click();
- assert.strictEqual($('.modal').length, 0,
- 'in edit, clicking on a subrecord should not open a dialog');
- assert.ok(form.$('.o_list_view tbody tr:first()').hasClass('o_selected_row'),
- 'first row should be in edition');
- form.$('.o_list_view input:first()').val('new name').trigger('input');
-
- form.$('.o_list_view tbody tr:nth(1) td:first').click(); // click on second record to validate the first one
- assert.ok(!form.$('.o_list_view tbody tr:first').hasClass('o_selected_row'),
- 'first row should not be in edition anymore');
- assert.strictEqual(form.$('.o_list_view tbody td:first').text(), 'new name',
- 'value of subrecord should have been updated');
-
- // create new subrecords
- // TODO when 'Add an item' will be implemented
- form.destroy();
- });
-
- QUnit.test('one2many list (editable): edition, part 2', function (assert) {
- assert.expect(8);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="p">' +
- '<tree editable="top">' +
- '<field name="foo"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- mockRPC: function (route, args) {
- if (args.method === 'write') {
- assert.strictEqual(args.args[1].p[0][0], 0,
- "should send a 0 command for field p");
- assert.strictEqual(args.args[1].p[1][0], 0,
- "should send a second 0 command for field p");
- }
- return this._super.apply(this, arguments);
- },
- });
-
- // edit mode, then click on Add an item and enter a value
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_field_x2many_list_row_add a').click();
- form.$('.o_selected_row > td input').val('kartoffel').trigger('input');
-
- // click again on Add an item
- form.$('.o_field_x2many_list_row_add a').click();
- assert.strictEqual(form.$('td:contains(kartoffel)').length, 1,
- "should have one td with the new value");
- assert.strictEqual(form.$('.o_selected_row > td input').length, 1,
- "should have one other new td");
- assert.strictEqual(form.$('tr.o_data_row').length, 2, "should have 2 data rows");
-
- // enter another value and save
- form.$('.o_selected_row > td input').val('gemuse').trigger('input');
- form.$buttons.find('.o_form_button_save').click();
- assert.strictEqual(form.$('tr.o_data_row').length, 2, "should have 2 data rows");
- assert.strictEqual(form.$('td:contains(kartoffel)').length, 1,
- "should have one td with the new value");
- assert.strictEqual(form.$('td:contains(gemuse)').length, 1,
- "should have one td with the new value");
-
- form.destroy();
- });
-
- QUnit.test('one2many list (editable): edition, part 3', function (assert) {
- assert.expect(3);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<group>' +
- '<field name="turtles">' +
- '<tree editable="top">' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '</field>' +
- '</group>' +
- '</form>',
- res_id: 1,
- });
-
- // edit mode, then click on Add an item 2 times
- assert.strictEqual(form.$('tr.o_data_row').length, 1,
- "should have 1 data rows");
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_field_x2many_list_row_add a').click();
- form.$('.o_field_x2many_list_row_add a').click();
- assert.strictEqual(form.$('tr.o_data_row').length, 3,
- "should have 3 data rows");
-
- // cancel the edition
- form.$buttons.find('.o_form_button_cancel').click();
- $('.modal-footer button.btn-primary').first().click();
- assert.strictEqual(form.$('tr.o_data_row').length, 1,
- "should have 1 data rows");
-
- form.destroy();
- });
-
- QUnit.test('one2many list (editable): edition, part 4', function (assert) {
- assert.expect(3);
- var i = 0;
-
- this.data.turtle.onchanges = {
- turtle_trululu: function (obj) {
- if (i) {
- obj.turtle_description = "Some Description";
- }
- i++;
- },
- };
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<group>' +
- '<field name="turtles">' +
- '<tree editable="top">' +
- '<field name="turtle_trululu"/>' +
- '<field name="turtle_description"/>' +
- '</tree>' +
- '</field>' +
- '</group>' +
- '</form>',
- res_id: 2,
- });
-
- // edit mode, then click on Add an item
- assert.strictEqual(form.$('tr.o_data_row').length, 0,
- "should have 0 data rows");
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_field_x2many_list_row_add a').click();
- assert.strictEqual(form.$('textarea').val(), "",
- "field turtle_description should be empty");
-
- // add a value in the turtle_trululu field to trigger an onchange
- var $dropdown = form.$('.o_field_many2one[name=turtle_trululu] input')
- .autocomplete('widget');
- form.$('.o_field_many2one[name=turtle_trululu] input').click();
- $dropdown.find('a:contains(first record)').mouseenter().click();
- assert.strictEqual(form.$('textarea').val(), "Some Description",
- "field turtle_description should be set to the result of the onchange");
- form.destroy();
- });
-
- QUnit.test('one2many list (editable): discarding required empty data', function (assert) {
- assert.expect(7);
-
- this.data.turtle.fields.turtle_foo.required = true;
- delete this.data.turtle.fields.turtle_foo.default;
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<group>' +
- '<field name="turtles">' +
- '<tree editable="top">' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '</field>' +
- '</group>' +
- '</form>',
- res_id: 2,
- mockRPC: function (route, args) {
- if (args.method) {
- assert.step(args.method);
- }
- return this._super.apply(this, arguments);
- },
- });
-
- // edit mode, then click on Add an item, then click elsewhere
- assert.strictEqual(form.$('tr.o_data_row').length, 0,
- "should have 0 data rows");
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_field_x2many_list_row_add a').click();
- form.$('label.o_form_label').first().click();
- assert.strictEqual(form.$('tr.o_data_row').length, 0,
- "should still have 0 data rows");
-
- // click on Add an item again, then click on save
- form.$('.o_field_x2many_list_row_add a').click();
- form.$buttons.find('.o_form_button_save').click();
- assert.strictEqual(form.$('tr.o_data_row').length, 0,
- "should still have 0 data rows");
-
- assert.verifySteps(['read', 'default_get', 'default_get']);
- form.destroy();
- });
-
- QUnit.test('editable one2many list, adding line when only one page', function (assert) {
- assert.expect(1);
-
- this.data.partner.records[0].turtles=[1,2,3];
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="turtles">' +
- '<tree editable="bottom" limit="3">' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- });
-
- // add a record, to reach the page size limit
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_field_x2many_list_row_add a').click();
-
- // the record currently being added should not count in the pager
- assert.ok(!form.$('.o_cp_pager').is(':visible'),
- "pager should not be visible");
- form.destroy();
- });
-
- QUnit.test('editable one2many list, adding line, then discarding', function (assert) {
- assert.expect(1);
-
- this.data.turtle.records.push({id:4, turtle_foo: 'stephen hawking'});
- this.data.partner.records[0].turtles = [1,2,3,4];
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="turtles">' +
- '<tree editable="bottom" limit="3">' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- });
-
- // add a record, then discard
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_field_x2many_list_row_add a').click();
- form.$buttons.find('.o_form_button_cancel').click();
-
- // confirm the discard operation
- $('.modal .modal-footer .btn-primary').click(); // click on confirm
-
- assert.strictEqual(form.$('.o_cp_pager').text().trim(), '1-3 / 4',
- "pager should still be visible");
- form.destroy();
- });
-
- QUnit.test('editable one2many list, required field and pager', function (assert) {
- assert.expect(1);
-
- this.data.turtle.records.push({id:4, turtle_foo: 'stephen hawking'});
- this.data.turtle.fields.turtle_foo.required = true;
- this.data.partner.records[0].turtles = [1,2,3,4];
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="turtles">' +
- '<tree editable="bottom" limit="3">' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- });
-
- // add a (empty) record
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_field_x2many_list_row_add a').click();
-
- // go on next page. The new record is not valid and should be discarded
- form.$('.o_pager_next').click();
- assert.strictEqual(form.$('tr.o_data_row').length, 1,
- "should have 1 data rows");
-
- form.destroy();
- });
-
- QUnit.test('editable one2many list, required field, pager and confirm discard', function (assert) {
- assert.expect(3);
-
- this.data.turtle.records.push({id:4, turtle_foo: 'stephen hawking'});
- this.data.turtle.fields.turtle_foo.required = true;
- this.data.partner.records[0].turtles = [1,2,3,4];
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="turtles">' +
- '<tree editable="bottom" limit="3">' +
- '<field name="turtle_foo"/>' +
- '<field name="turtle_int"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- });
-
- // add a record with a dirty state, but not valid
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_field_x2many_list_row_add a').click();
- form.$('input[name="turtle_int"]').val(4321).trigger('input');
-
- // go to next page. The new record is not valid, but dirty. we should
- // see a confirm dialog
- form.$('.o_pager_next').click();
-
- assert.strictEqual(form.$('.o_cp_pager').text().trim(), '1-3 / 4',
- "pager should still display the correct total");
-
- // click on cancel
- $('.modal .modal-footer .btn-secondary').click(); // click on cancel
-
- assert.strictEqual(form.$('.o_cp_pager').text().trim(), '1-3 / 4',
- "pager should again display the correct total");
- assert.strictEqual(form.$('.o_field_one2many input.o_field_invalid').length, 1,
- "there should be an invalid input in the one2many");
- form.destroy();
- });
-
- QUnit.test('editable one2many list, adding, discarding, and pager', function (assert) {
- assert.expect(2);
-
- this.data.partner.records[0].turtles = [1];
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="turtles">' +
- '<tree editable="bottom" limit="3">' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- });
-
- // add a 4 records record (to make the pager appear)
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_field_x2many_list_row_add a').click();
- form.$('.o_field_x2many_list_row_add a').click();
- form.$('.o_field_x2many_list_row_add a').click();
- form.$('.o_field_x2many_list_row_add a').click();
-
- // go on next page
- form.$('.o_pager_next').click();
-
- // discard
- form.$buttons.find('.o_form_button_cancel').click();
- $('.modal .modal-footer .btn-primary').click();
-
- assert.strictEqual(form.$('tr.o_data_row').length, 1,
- "should have 1 data row");
- assert.ok(!form.$('.o_cp_pager').is(':visible'),
- "pager should not be visible");
-
- form.destroy();
- });
-
- QUnit.test('unselecting a line with missing required data', function (assert) {
- assert.expect(5);
-
- this.data.turtle.fields.turtle_foo.required = true;
- delete this.data.turtle.fields.turtle_foo.default;
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<group>' +
- '<field name="turtles">' +
- '<tree editable="top">' +
- '<field name="turtle_foo"/>' +
- '<field name="turtle_int"/>' +
- '</tree>' +
- '</field>' +
- '</group>' +
- '</form>',
- res_id: 2,
- });
-
- // edit mode, then click on Add an item, then click elsewhere
- assert.strictEqual(form.$('tr.o_data_row').length, 0,
- "should have 0 data rows");
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_field_x2many_list_row_add a').click();
- assert.strictEqual(form.$('tr.o_data_row').length, 1,
- "should have 1 data rows");
-
- // adding a value in the non required field, so it is dirty, but with
- // a missing required field
- form.$('input[name="turtle_int"]').val('12345').trigger('input');
-
- // click elsewhere,
- form.$('label.o_form_label').click();
- assert.strictEqual($('.modal').length, 1,
- 'a confirmation model should be opened');
-
- // click on cancel, the line should still be selected
- $('.modal .modal-footer button.btn-secondary').click();
- assert.strictEqual(form.$('tr.o_data_row.o_selected_row').length, 1,
- "should still have 1 selected data row");
-
- // click elsewhere, and click on ok (on the confirmation dialog)
- form.$('label.o_form_label').click();
- $('.modal .modal-footer button.btn-primary').click();
- assert.strictEqual(form.$('tr.o_data_row').length, 0,
- "should have 0 data rows (invalid line has been discarded");
-
- form.destroy();
- });
-
- QUnit.test('pressing enter in a o2m with a required empty field', function (assert) {
- assert.expect(4);
-
- this.data.turtle.fields.turtle_foo.required = true;
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<group>' +
- '<field name="turtles">' +
- '<tree editable="top">' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '</field>' +
- '</group>' +
- '</form>',
- res_id: 2,
- mockRPC: function (route, args) {
- assert.step(args.method);
- return this._super.apply(this, arguments);
- },
- });
-
- // edit mode, then click on Add an item, then click elsewhere
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_field_x2many_list_row_add a').click();
- form.$('input[name="turtle_foo"]').trigger($.Event('keydown', {
- which: $.ui.keyCode.ENTER,
- keyCode: $.ui.keyCode.ENTER,
- }));
- assert.ok(form.$('input[name="turtle_foo"]').hasClass('o_field_invalid'),
- "input should be marked invalid");
- assert.verifySteps(['read', 'default_get']);
- form.destroy();
- });
-
- QUnit.test('editing a o2m, with required field and onchange', function (assert) {
- assert.expect(12);
-
- this.data.turtle.fields.turtle_foo.required = true;
- delete this.data.turtle.fields.turtle_foo.default;
- this.data.turtle.onchanges = {
- turtle_foo: function (obj) {
- obj.turtle_int = obj.turtle_foo.length;
- },
- };
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<group>' +
- '<field name="turtles">' +
- '<tree editable="top">' +
- '<field name="turtle_foo"/>' +
- '<field name="turtle_int"/>' +
- '</tree>' +
- '</field>' +
- '</group>' +
- '</form>',
- res_id: 2,
- mockRPC: function (route, args) {
- if (args.method) {
- assert.step(args.method);
- }
- return this._super.apply(this, arguments);
- },
- });
-
- // edit mode, then click on Add an item
- assert.strictEqual(form.$('tr.o_data_row').length, 0,
- "should have 0 data rows");
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_field_x2many_list_row_add a').click();
-
- // input some text in required turtle_foo field
- form.$('input[name="turtle_foo"]').val('aubergine').trigger('input');
- assert.strictEqual(form.$('input[name="turtle_int"]').val(), "9",
- "onchange should have been triggered");
-
- // save and check everything is fine
- form.$buttons.find('.o_form_button_save').click();
- assert.strictEqual(form.$('.o_data_row td:contains(aubergine)').length, 1,
- "should have one row with turtle_foo value");
- assert.strictEqual(form.$('.o_data_row td:contains(9)').length, 1,
- "should have one row with turtle_int value");
-
- assert.verifySteps(['read', 'default_get', 'onchange', 'onchange', 'write', 'read', 'read']);
- form.destroy();
- });
-
- QUnit.test('editable o2m, pressing ESC discard current changes', function (assert) {
- assert.expect(5);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="turtles">' +
- '<tree editable="top">' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 2,
- mockRPC: function (route, args) {
- assert.step(args.method);
- return this._super.apply(this, arguments);
- },
- });
-
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_field_x2many_list_row_add a').click();
- assert.strictEqual(form.$('tr.o_data_row').length, 1,
- "there should be one data row");
-
- form.$('input[name="turtle_foo"]').trigger({type: 'keydown', which: $.ui.keyCode.ESCAPE});
- assert.strictEqual(form.$('tr.o_data_row').length, 0,
- "data row should have been discarded");
- assert.verifySteps(['read', 'default_get']);
- form.destroy();
- });
-
- QUnit.test('editable o2m with required field, pressing ESC discard current changes', function (assert) {
- assert.expect(5);
-
- this.data.turtle.fields.turtle_foo.required = true;
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="turtles">' +
- '<tree editable="top">' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 2,
- mockRPC: function (route, args) {
- assert.step(args.method);
- return this._super.apply(this, arguments);
- },
- });
-
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_field_x2many_list_row_add a').click();
- assert.strictEqual(form.$('tr.o_data_row').length, 1,
- "there should be one data row");
-
- form.$('input[name="turtle_foo"]').trigger({type: 'keydown', which: $.ui.keyCode.ESCAPE});
- assert.strictEqual(form.$('tr.o_data_row').length, 0,
- "data row should have been discarded");
- assert.verifySteps(['read', 'default_get']);
- form.destroy();
- });
-
- QUnit.test('pressing escape in editable o2m list in dialog', function (assert) {
- assert.expect(3);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<group>' +
- '<field name="p">' +
- '<tree>' +
- '<field name="display_name"/>' +
- '</tree>' +
- '</field>' +
- '</group>' +
- '</form>',
- res_id: 1,
- archs: {
- "partner,false,form": '<form>' +
- '<field name="p">' +
- '<tree editable="bottom">' +
- '<field name="display_name"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- },
- viewOptions: {
- mode: 'edit',
- },
- });
-
- form.$('.o_field_x2many_list_row_add a').click();
- $('.modal .o_field_x2many_list_row_add a').click();
-
- assert.strictEqual($('.modal .o_data_row.o_selected_row').length, 1,
- "there should be a row in edition in the dialog");
-
- // trigger keydown ESCAPE in the edited row
- $('.modal .o_data_cell input').trigger({type: 'keydown', which: $.ui.keyCode.ESCAPE});
-
- assert.strictEqual($('.modal').length, 1,
- "dialog should still be open");
- assert.strictEqual($('.modal .o_data_row').length, 0,
- "the row should have been removed");
-
- form.destroy();
- });
-
- QUnit.test('editable o2m with onchange and required field: delete an invalid line', function (assert) {
- assert.expect(5);
-
- this.data.partner.onchanges = {
- turtles: function () {},
- };
- this.data.partner.records[0].turtles = [1];
- this.data.turtle.records[0].product_id = 37;
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="turtles">' +
- '<tree editable="top">' +
- '<field name="product_id"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- mockRPC: function (route, args) {
- assert.step(args.method);
- return this._super.apply(this, arguments);
- },
- viewOptions: {
- mode: 'edit',
- },
- });
-
- form.$('.o_data_cell:first').click();
- form.$('.o_field_widget[name="product_id"] input').val('').trigger('keyup');
- assert.verifySteps(['read', 'read'], 'no onchange should be done as line is invalid');
- form.$('.o_list_record_remove').click();
- assert.verifySteps(['read', 'read', 'onchange'], 'onchange should have been done');
-
- form.destroy();
- });
-
- QUnit.test('onchange in a one2many', function (assert) {
- assert.expect(1);
-
- this.data.partner.records.push({
- id: 3,
- foo: "relational record 1",
- });
- this.data.partner.records[1].p = [3];
- this.data.partner.onchanges = {p: true};
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="p">' +
- '<tree editable="top">' +
- '<field name="foo"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 2,
- mockRPC: function (route, args) {
- if (args.method === 'onchange') {
- return $.when({value: { p: [
- [5], // delete all
- [0, 0, {foo: "from onchange"}], // create new
- ]}});
- }
- return this._super(route, args);
- },
- });
-
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_field_one2many tbody td').first().click();
- form.$('.o_field_one2many tbody td').first().find('input').val("new value").trigger('input');
- form.$buttons.find('.o_form_button_save').click();
-
- assert.strictEqual(form.$('.o_field_one2many tbody td').first().text(), 'from onchange',
- "display name of first record in o2m list should be 'new value'");
- form.destroy();
- });
-
- QUnit.test('one2many, default_get and onchange (basic)', function (assert) {
- assert.expect(1);
-
- this.data.partner.fields.p.default = [
- [6, 0, []], // replace with zero ids
- ];
- this.data.partner.onchanges = {p: true};
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="p">' +
- '<tree>' +
- '<field name="foo"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- mockRPC: function (route, args) {
- if (args.method === 'onchange') {
- return $.when({value: { p: [
- [5], // delete all
- [0, 0, {foo: "from onchange"}], // create new
- ]}});
- }
- return this._super(route, args);
- },
- });
-
- assert.ok(form.$('td:contains(from onchange)').length,
- "should have 'from onchange' value in one2many");
- form.destroy();
- });
-
- QUnit.test('one2many and default_get (with date)', function (assert) {
- assert.expect(1);
-
- this.data.partner.fields.p.default = [
- [0, false, {date: '2017-10-08'}],
- ];
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="p">' +
- '<tree>' +
- '<field name="date"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- });
-
- assert.strictEqual(form.$('.o_data_cell').text(), '10/08/2017',
- "should correctly display the date");
-
- form.destroy();
- });
-
- QUnit.test('one2many and onchange (with integer)', function (assert) {
- assert.expect(4);
-
- this.data.turtle.onchanges = {
- turtle_int: function (obj) {}
- };
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="turtles">' +
- '<tree editable="bottom">' +
- '<field name="turtle_int"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- mockRPC: function (route, args) {
- assert.step(args.method);
- return this._super.apply(this, arguments);
- },
- });
- form.$buttons.find('.o_form_button_edit').click();
-
- form.$('td:contains(9)').click();
- form.$('td input[name="turtle_int"]').val("3").trigger('input');
-
- // the 'change' event is triggered on the input when we focus somewhere
- // else, for example by clicking in the body. However, if we try to
- // programmatically click in the body, it does not trigger a change
- // event, so we simply trigger it directly instead.
- form.$('td input[name="turtle_int"]').trigger('change');
-
- assert.verifySteps(['read', 'read', 'onchange']);
- form.destroy();
- });
-
- QUnit.test('one2many and onchange (with date)', function (assert) {
- assert.expect(7);
-
- this.data.partner.onchanges = {
- date: function (obj) {}
- };
- this.data.partner.records[0].p = [2];
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="p">' +
- '<tree editable="bottom">' +
- '<field name="date"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- mockRPC: function (route, args) {
- assert.step(args.method);
- return this._super.apply(this, arguments);
- },
- });
- form.$buttons.find('.o_form_button_edit').click();
-
- form.$('td:contains(01/25/2017)').click();
- form.$('.o_datepicker_input').click();
- $('.bootstrap-datetimepicker-widget .picker-switch').first().click(); // Month selection
- $('.bootstrap-datetimepicker-widget .picker-switch').first().click(); // Year selection
- $('.bootstrap-datetimepicker-widget .year:contains(2017)').click();
- $('.bootstrap-datetimepicker-widget .month').eq(1).click(); // February
- $('.day:contains(22)').click(); // select the 22 February
-
- form.$buttons.find('.o_form_button_save').click();
-
- assert.verifySteps(['read', 'read', 'onchange', 'write', 'read', 'read']);
- form.destroy();
- });
-
- QUnit.test('one2many and onchange (with command DELETE_ALL)', function (assert) {
- assert.expect(5);
-
- this.data.partner.onchanges = {
- foo: function (obj) {
- obj.p = [[5]];
- },
- p: function () {}, // dummy onchange on the o2m to execute _isX2ManyValid()
- };
- this.data.partner.records[0].p = [2];
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="foo"/>' +
- '<field name="p">' +
- '<tree editable="bottom">' +
- '<field name="display_name"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- mockRPC: function (method, args) {
- if (args.method === 'write') {
- assert.deepEqual(args.args[1].p, [
- [0, args.args[1].p[0][1], {display_name: 'z'}],
- [2, 2, false],
- ], "correct commands should be sent");
- }
- return this._super.apply(this, arguments);
- },
- res_id: 1,
- viewOptions: {
- mode: 'edit',
- },
- });
-
- assert.strictEqual(form.$('.o_data_row').length, 1,
- "o2m should contain one row");
-
- // empty o2m by triggering the onchange
- form.$('.o_field_widget[name=foo]').val('trigger onchange').trigger('input');
-
- assert.strictEqual(form.$('.o_data_row').length, 0,
- "rows of the o2m should have been deleted");
-
- // add two new subrecords
- form.$('.o_field_x2many_list_row_add a').click();
- form.$('.o_field_widget[name=display_name]').val('x').trigger('input');
- form.$('.o_field_x2many_list_row_add a').click();
- form.$('.o_field_widget[name=display_name]').val('y').trigger('input');
-
- assert.strictEqual(form.$('.o_data_row').length, 2,
- "o2m should contain two rows");
-
- // empty o2m by triggering the onchange
- form.$('.o_field_widget[name=foo]').val('trigger onchange again').trigger('input');
-
- assert.strictEqual(form.$('.o_data_row').length, 0,
- "rows of the o2m should have been deleted");
-
- form.$('.o_field_x2many_list_row_add a').click();
- form.$('.o_field_widget[name=display_name]').val('z').trigger('input');
-
- form.$buttons.find('.o_form_button_save').click();
- form.destroy();
- });
-
- QUnit.test('one2many and onchange only write modified field', function (assert) {
- assert.expect(2);
-
- this.data.partner.onchanges = {
- turtles: function (obj) {
- obj.turtles = [
- [5], // delete all
- [1, 3, { // the server returns all fields
- display_name: "coucou",
- product_id: [37, "xphone"],
- turtle_bar: false,
- turtle_foo: "has changed",
- turtle_int: 42,
- turtle_qux: 9.8,
- partner_ids: [],
- turtle_ref: 'product,37',
- }],
- ];
- },
- };
-
- this.data.partner.records[0].turtles = [3];
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="foo"/>' +
- '<field name="turtles">' +
- '<tree editable="bottom">' +
- '<field name="display_name"/>' +
- '<field name="product_id"/>' +
- '<field name="turtle_bar"/>' +
- '<field name="turtle_foo"/>' +
- '<field name="turtle_int"/>' +
- '<field name="turtle_qux"/>' +
- '<field name="turtle_ref"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- mockRPC: function (method, args) {
- if (args.method === 'write') {
- assert.deepEqual(args.args[1].turtles, [
- [1, 3, {display_name: 'coucou', turtle_foo: 'has changed', turtle_int: 42}],
- ], "correct commands should be sent (only send changed values)");
- }
- return this._super.apply(this, arguments);
- },
- res_id: 1,
- viewOptions: {
- mode: 'edit',
- },
- });
-
- assert.strictEqual(form.$('.o_data_row').length, 1,
- "o2m should contain one row");
-
- 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');
-
- form.$buttons.find('.o_form_button_save').click();
- form.destroy();
- });
-
- QUnit.test('one2many with CREATE onchanges correctly refreshed', function (assert) {
- assert.expect(5);
-
- var delta = 0;
- testUtils.patch(AbstractField, {
- init: function () {
- delta++;
- this._super.apply(this, arguments);
- },
- destroy: function () {
- delta--;
- this._super.apply(this, arguments);
- },
- });
-
- var deactiveOnchange = true;
-
- this.data.partner.records[0].turtles = [];
- this.data.partner.onchanges = {
- turtles: function (obj) {
- if (deactiveOnchange) { return; }
- // the onchange will either:
- // - create a second line if there is only one line
- // - edit the second line if there are two lines
- if (obj.turtles.length === 1) {
- obj.turtles = [
- [5], // delete all
- [0, obj.turtles[0][1], {
- display_name: "first",
- turtle_int: obj.turtles[0][2].turtle_int,
- }],
- [0, 0, {
- display_name: "second",
- turtle_int: -obj.turtles[0][2].turtle_int,
- }],
- ];
- } else if (obj.turtles.length === 2) {
- obj.turtles = [
- [5], // delete all
- [0, obj.turtles[0][1], {
- display_name: "first",
- turtle_int: obj.turtles[0][2].turtle_int,
- }],
- [0, obj.turtles[1][1], {
- display_name: "second",
- turtle_int: -obj.turtles[0][2].turtle_int,
- }],
- ];
- }
- },
- };
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="foo"/>' +
- '<field name="turtles">' +
- '<tree editable="bottom">' +
- '<field name="display_name" widget="char"/>' +
- '<field name="turtle_int"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- viewOptions: {
- mode: 'edit',
- },
- });
-
- assert.strictEqual(form.$('.o_data_row').length, 0,
- "o2m shouldn't contain any row");
-
- form.$('.o_field_x2many_list_row_add a').click();
- // trigger the first onchange
- deactiveOnchange = false;
- form.$('input[name="turtle_int"]').val('10').trigger('input');
- // put the list back in non edit mode
- form.$('input[name="foo"]').click();
- assert.strictEqual(form.$('.o_data_row').text(), "first10second-10",
- "should correctly refresh the records");
-
- // trigger the second onchange
- form.$('.o_field_x2many_list tbody tr:first td:first').click();
- form.$('input[name="turtle_int"]').val('20').trigger('input');
-
- form.$('input[name="foo"]').click();
- assert.strictEqual(form.$('.o_data_row').text(), "first20second-20",
- "should correctly refresh the records");
-
- assert.strictEqual(form.$('.o_field_widget').length, delta,
- "all (non visible) field widgets should have been destroyed");
-
- form.$buttons.find('.o_form_button_save').click();
-
- assert.strictEqual(form.$('.o_data_row').text(), "first20second-20",
- "should correctly refresh the records after save");
-
- form.destroy();
- 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) {
- assert.expect(2);
-
- this.data.partner.records[0].p = [1, 2, 4];
- this.data.partner.onchanges = {
- p: function () {},
- };
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="p">' +
- '<tree editable="bottom">' +
- '<field name="display_name"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- viewOptions: {
- mode: 'edit',
- },
- });
-
- assert.strictEqual(form.$('.o_data_cell').text(), 'first recordsecond recordaaa',
- "records should be display in the correct order");
-
- form.$('.o_data_row:first .o_data_cell').click();
- form.$('.o_selected_row .o_field_widget[name=display_name]').val('new').trigger('input');
- form.$el.click(); // click outside to validate the row
-
- assert.strictEqual(form.$('.o_data_cell').text(), 'newsecond recordaaa',
- "records should be display in the correct order");
-
- form.destroy();
- });
-
- QUnit.test('one2many list (editable): readonly domain is evaluated', function (assert) {
- assert.expect(2);
-
- this.data.partner.records[0].p = [2, 4];
- this.data.partner.records[1].product_id = false;
- this.data.partner.records[2].product_id = 37;
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="p">' +
- '<tree editable="top">' +
- '<field name="display_name" attrs=\'{"readonly": [["product_id", "=", false]]}\'/>' +
- '<field name="product_id"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- });
-
- form.$buttons.find('.o_form_button_edit').click();
-
- assert.ok(form.$('.o_list_view tbody tr:eq(0) td:first').hasClass('o_readonly_modifier'),
- "first record should have display_name in readonly mode");
-
- assert.notOk(form.$('.o_list_view tbody tr:eq(1) td:first').hasClass('o_readonly_modifier'),
- "second record should not have display_name in readonly mode");
- form.destroy();
- });
-
- QUnit.test('pager of one2many field in new record', function (assert) {
- assert.expect(2);
-
- this.data.partner.records[0].p = [];
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="p">' +
- '<tree editable="top">' +
- '<field name="foo"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- archs: {
- 'partner,false,form':
- '<form string="Partner"><field name="foo"/></form>',
- },
- });
-
- assert.ok(!form.$('.o_x2m_control_panel .o_cp_pager div').is(':visible'),
- 'o2m pager should be hidden');
-
- // click to create a subrecord
- form.$('tbody td.o_field_x2many_list_row_add a').click();
- $('.modal input').val('new record').trigger('input');
- $('.modal .modal-footer button:eq(0)').click(); // save and close
-
- assert.ok(!form.$('.o_x2m_control_panel .o_cp_pager div').is(':visible'),
- 'o2m pager should be hidden');
- form.destroy();
- });
-
- QUnit.test('one2many list with a many2one', function (assert) {
- assert.expect(5);
-
- this.data.partner.records[0].p = [2];
- this.data.partner.records[1].product_id = 37;
- this.data.partner.onchanges.p = function (obj) {
- obj.p = [
- [5], // delete all
- [1, 2, {product_id: [37, "xphone"]}], // update existing record
- [0, 0, {product_id: [41, "xpad"]}]
- ];
- //
- };
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="p">' +
- '<tree>' +
- '<field name="product_id"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- archs: {
- 'partner,false,form':
- '<form string="Partner"><field name="product_id"/></form>',
- },
- mockRPC: function (route, args) {
- if (args.method === 'onchange') {
- assert.deepEqual(args.args[1].p, [[4, 2, false], [0, args.args[1].p[1][1], {product_id: 41}]],
- "should trigger onchange with correct parameters");
- }
- return this._super.apply(this, arguments);
- }
- });
-
- assert.strictEqual(form.$('tbody td:contains(xphone)').length, 1,
- "should have properly fetched the many2one nameget");
- assert.strictEqual(form.$('tbody td:contains(xpad)').length, 0,
- "should not display 'xpad' anywhere");
-
- form.$buttons.find('.o_form_button_edit').click();
-
- form.$('tbody td.o_field_x2many_list_row_add a').click();
-
- $('.modal .o_field_many2one input').click();
-
- var $dropdown = $('.modal .o_field_many2one input').autocomplete('widget');
-
- $dropdown.find('li:eq(1) a').mouseenter();
- $dropdown.find('li:eq(1) a').click();
-
- $('.modal .modal-footer button:eq(0)').click(); // save and close
-
- assert.strictEqual(form.$('tbody td:contains(xpad)').length, 1,
- "should display 'xpad' on a td");
- assert.strictEqual(form.$('tbody td:contains(xphone)').length, 1,
- "should still display xphone");
- form.destroy();
- });
-
- QUnit.test('one2many list with inline form view', function (assert) {
- assert.expect(5);
-
- this.data.partner.records[0].p = [];
-
- var rpcCount = 0;
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="p">' +
- '<form string="Partner">' +
- '<field name="product_id"/>' +
- '<field name="int_field"/>' +
- '</form>' +
- '<tree>' +
- '<field name="product_id"/>' +
- '<field name="foo"/>' + // don't remove this, it is
- // useful to make sure the foo fieldwidget
- // does not crash because the foo field
- // is not in the form view
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- mockRPC: function (route, args) {
- rpcCount++;
- if (args.method === 'write') {
- assert.deepEqual(args.args[1].p, [[0, args.args[1].p[0][1], {
- foo: false, int_field: 123, product_id: 41,
- }]]);
- }
- return this._super(route, args);
- },
- });
-
- form.$buttons.find('.o_form_button_edit').click();
-
- form.$('tbody td.o_field_x2many_list_row_add a').click();
-
- // write in the many2one field, value = 37 (xphone)
- $('.modal .o_field_many2one input').click();
- var $dropdown = $('.modal .o_field_many2one input').autocomplete('widget');
- $dropdown.find('li:eq(0) a').mouseenter();
- $dropdown.find('li:eq(0) a').click();
-
- // write in the integer field
- $('.modal .modal-body input.o_field_widget').val('123').trigger('input');
-
- // save and close
- $('.modal .modal-footer button:eq(0)').click();
-
- assert.strictEqual(form.$('tbody td:contains(xphone)').length, 1,
- "should display 'xphone' in a td");
-
- // reopen the record in form view
- form.$('tbody td:contains(xphone)').click();
-
- assert.strictEqual($('.modal .modal-body input').val(), "xphone",
- "should display 'xphone' in an input");
-
- $('.modal .modal-body input.o_field_widget').val('456').trigger('input');
-
- // discard
- $('.modal .modal-footer span:contains(Discard)').click();
-
- // reopen the record in form view
- form.$('tbody td:contains(xphone)').click();
-
- assert.strictEqual($('.modal .modal-body input.o_field_widget').val(), "123",
- "should display 123 (previous change has been discarded)");
-
- // write in the many2one field, value = 41 (xpad)
- $('.modal .o_field_many2one input').click();
- $dropdown = $('.modal .o_field_many2one input').autocomplete('widget');
- $dropdown.find('li:eq(1) a').mouseenter();
- $dropdown.find('li:eq(1) a').click();
-
- // save and close
- $('.modal .modal-footer button:eq(0)').click();
-
- assert.strictEqual(form.$('tbody td:contains(xpad)').length, 1,
- "should display 'xpad' in a td");
-
- // save the record
- form.$buttons.find('.o_form_button_save').click();
- form.destroy();
- });
-
- QUnit.test('one2many list with inline form view with context with parent key', function (assert) {
- assert.expect(2);
-
- this.data.partner.records[0].p = [2];
- this.data.partner.records[0].product_id = 41;
- this.data.partner.records[1].product_id = 37;
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="foo"/>' +
- '<field name="product_id"/>' +
- '<field name="p">' +
- '<form string="Partner">' +
- '<field name="product_id" context="{\'partner_foo\':parent.foo, \'lalala\': parent.product_id}"/>' +
- '</form>' +
- '<tree>' +
- '<field name="product_id"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- mockRPC: function (route, args) {
- if (args.method === 'name_search') {
- assert.strictEqual(args.kwargs.context.partner_foo, "yop",
- "should have correctly evaluated parent foo field");
- assert.strictEqual(args.kwargs.context.lalala, 41,
- "should have correctly evaluated parent product_id field");
- }
- return this._super.apply(this, arguments);
- },
- });
-
- form.$buttons.find('.o_form_button_edit').click();
- // open a modal
- form.$('tr.o_data_row:eq(0) td:contains(xphone)').click();
-
- // write in the many2one field
- $('.modal .o_field_many2one input').click();
-
- form.destroy();
- });
-
- QUnit.test('value of invisible x2many fields is correctly evaluated in context', function (assert) {
- assert.expect(1);
-
- this.data.partner.records[0].timmy = [12];
- this.data.partner.records[0].p = [2, 3];
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:
- '<form string="Partners">' +
- '<field name="product_id" context="{\'p\': p, \'timmy\': timmy}"/>' +
- '<field name="p" invisible="1"/>' +
- '<field name="timmy" invisible="1"/>' +
- '</form>',
- res_id: 1,
- mockRPC: function (route, args) {
- if (args.method === 'name_search') {
- assert.deepEqual(
- args.kwargs.context, {
- p: [[4, 2, false], [4, 3, false]],
- timmy: [[6, false, [12]]],
- }, 'values of x2manys should have been correctly evaluated in context');
- }
- return this._super.apply(this, arguments);
- },
- });
-
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_field_widget[name=product_id] input').click();
-
- form.destroy();
- });
-
- QUnit.test('one2many list, editable, with many2one and with context with parent key', function (assert) {
- assert.expect(1);
-
- this.data.partner.records[0].p = [2];
- this.data.partner.records[1].product_id = 37;
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="foo"/>' +
- '<field name="p">' +
- '<tree editable="bottom">' +
- '<field name="product_id" context="{\'partner_foo\':parent.foo}"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- mockRPC: function (route, args) {
- if (args.method === 'name_search') {
- assert.strictEqual(args.kwargs.context.partner_foo, "yop",
- "should have correctly evaluated parent foo field");
- }
- return this._super.apply(this, arguments);
- },
- });
-
- form.$buttons.find('.o_form_button_edit').click();
-
- form.$('tr.o_data_row:eq(0) td:contains(xphone)').click();
-
- // trigger a name search
- form.$('table td input').click();
-
- form.destroy();
- });
-
- QUnit.test('one2many list, editable, with a date in the context', function (assert) {
- assert.expect(1);
-
- this.data.partner.records[0].p = [2];
- this.data.partner.records[1].product_id = 37;
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<group>' +
- '<field name="date"/>' +
- '<field name="p" context="{\'date\':date}">' +
- '<tree editable="top">' +
- '<field name="date"/>' +
- '</tree>' +
- '</field>' +
- '</group>' +
- '</form>',
- res_id: 2,
- mockRPC: function (route, args) {
- if (args.method === 'default_get') {
- assert.strictEqual(args.kwargs.context.date, '2017-01-25',
- "should have properly evaluated date key in context");
- }
- return this._super.apply(this, arguments);
- },
- });
-
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_field_x2many_list_row_add a').click();
-
- form.destroy();
- });
-
- QUnit.test('one2many field with context', function (assert) {
- assert.expect(2);
-
- var counter = 0;
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<group>' +
- '<field name="turtles" context="{\'turtles\':turtles}">' +
- '<tree editable="bottom">' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '</field>' +
- '</group>' +
- '</form>',
- res_id: 1,
- mockRPC: function (route, args) {
- if (args.method === 'default_get') {
- var expected = counter === 0 ?
- [[4, 2, false]] :
- [[4, 2, false], [0, args.kwargs.context.turtles[1][1], {turtle_foo: 'hammer'}]];
- assert.deepEqual(args.kwargs.context.turtles, expected,
- "should have properly evaluated turtles key in context");
- counter++;
- }
- return this._super.apply(this, arguments);
- },
- });
-
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_field_x2many_list_row_add a').click();
- form.$('input[name="turtle_foo"]').val('hammer').trigger('input');
- form.$('.o_field_x2many_list_row_add a').click();
- form.destroy();
- });
-
- QUnit.test('one2many list edition, some basic functionality', function (assert) {
- assert.expect(3);
-
- this.data.partner.fields.foo.default = false;
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="p">' +
- '<tree editable="top">' +
- '<field name="foo"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- });
- form.$buttons.find('.o_form_button_edit').click();
-
- form.$('tbody td.o_field_x2many_list_row_add a').click();
-
- assert.strictEqual(form.$('td input.o_field_widget').length, 1,
- "should have created a row in edit mode");
-
- form.$('td input.o_field_widget').val('a').trigger('input');
-
- assert.strictEqual(form.$('td input.o_field_widget').length, 1,
- "should not have unselected the row after edition");
-
- form.$('td input.o_field_widget').val('abc').trigger('input');
- form.$buttons.find('.o_form_button_save').click();
-
- assert.strictEqual(form.$('td:contains(abc)').length, 1,
- "should have a row with the correct value");
- form.destroy();
- });
-
- QUnit.test('one2many list, the context is properly evaluated and sent', function (assert) {
- assert.expect(2);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="int_field"/>' +
- '<field name="p" context="{\'hello\': \'world\', \'abc\': int_field}">' +
- '<tree editable="top">' +
- '<field name="foo"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- mockRPC: function (route, args) {
- if (args.method === 'default_get') {
- var context = args.kwargs.context;
- assert.strictEqual(context.hello, "world");
- assert.strictEqual(context.abc, 10);
- }
- return this._super.apply(this, arguments);
- },
- });
-
- form.$buttons.find('.o_form_button_edit').click();
- form.$('tbody td.o_field_x2many_list_row_add a').click();
- form.destroy();
- });
-
- QUnit.test('one2many with many2many widget: create', function (assert) {
- assert.expect(10);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="turtles" widget="many2many">' +
- '<tree>' +
- '<field name="turtle_foo"/>' +
- '<field name="turtle_qux"/>' +
- '<field name="turtle_int"/>' +
- '<field name="product_id"/>' +
- '</tree>' +
- '<form>' +
- '<group>' +
- '<field name="turtle_foo"/>' +
- '<field name="turtle_bar"/>' +
- '<field name="turtle_int"/>' +
- '<field name="product_id"/>' +
- '</group>' +
- '</form>' +
- '</field>' +
- '</form>',
- archs: {
- 'turtle,false,list': '<tree><field name="display_name"/><field name="turtle_foo"/><field name="turtle_bar"/><field name="product_id"/></tree>',
- 'turtle,false,search': '<search><field name="turtle_foo"/><field name="turtle_bar"/><field name="product_id"/></search>',
- },
- session: {},
- res_id: 1,
- mockRPC: function (route, args) {
- if (route === '/web/dataset/call_kw/turtle/create') {
- assert.ok(args.args, "should write on the turtle record");
- }
- if (route === '/web/dataset/call_kw/partner/write') {
- assert.strictEqual(args.args[0][0], 1, "should write on the partner record 1");
- assert.strictEqual(args.args[1].turtles[0][0], 6, "should send only a 'replace with' command");
- }
- return this._super.apply(this, arguments);
- },
- });
-
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_field_x2many_list_row_add a').click();
-
- assert.strictEqual($('.modal .o_data_row').length, 2,
- "sould have 2 records in the select view (the last one is not displayed because it is already selected)");
-
- $('.modal .o_data_row:first .o_list_record_selector input').click();
- $('.modal .o_select_button').click();
- $('.o_form_button_save').click();
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_field_x2many_list_row_add a').click();
-
- assert.strictEqual($('.modal .o_data_row').length, 1,
- "sould have 1 record in the select view");
-
- $('.modal-footer button:eq(1)').click();
- $('.modal input.o_field_widget[name="turtle_foo"]').val('tototo').trigger('input');
- $('.modal input.o_field_widget[name="turtle_int"]').val(50).trigger('input');
- var $many2one = $('.modal [name="product_id"] input').click();
- var $dropdown = $many2one.autocomplete('widget');
- $dropdown.find('li:first a').mouseenter();
- $dropdown.find('li:first a').click();
-
- $('.modal-footer button:contains(&):first').click();
-
- assert.strictEqual($('.modal').length, 0, "sould close the modals");
-
- assert.strictEqual(form.$('.o_data_row').length, 3,
- "sould have 3 records in one2many list");
- assert.strictEqual(form.$('.o_data_row').text(), "blip1.59yop1.50tototo1.550xphone",
- "should display the record values in one2many list");
-
- $('.o_form_button_save').click();
-
- form.destroy();
- });
-
- QUnit.test('one2many with many2many widget: edition', function (assert) {
- assert.expect(7);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="turtles" widget="many2many">' +
- '<tree>' +
- '<field name="turtle_foo"/>' +
- '<field name="turtle_qux"/>' +
- '<field name="turtle_int"/>' +
- '<field name="product_id"/>' +
- '</tree>' +
- '<form>' +
- '<group>' +
- '<field name="turtle_foo"/>' +
- '<field name="turtle_bar"/>' +
- '<field name="turtle_int"/>' +
- '<field name="turtle_trululu"/>' +
- '<field name="product_id"/>' +
- '</group>' +
- '</form>' +
- '</field>' +
- '</form>',
- archs: {
- 'turtle,false,list': '<tree><field name="display_name"/><field name="turtle_foo"/><field name="turtle_bar"/><field name="product_id"/></tree>',
- 'turtle,false,search': '<search><field name="turtle_foo"/><field name="turtle_bar"/><field name="product_id"/></search>',
- },
- session: {},
- res_id: 1,
- mockRPC: function (route, args) {
- if (route === '/web/dataset/call_kw/turtle/write') {
- assert.strictEqual(args.args[0].length, 1, "should write on the turtle record");
- assert.deepEqual(args.args[1], {"product_id":37}, "should write only the product_id on the turtle record");
- }
- if (route === '/web/dataset/call_kw/partner/write') {
- assert.strictEqual(args.args[0][0], 1, "should write on the partner record 1");
- assert.strictEqual(args.args[1].turtles[0][0], 6, "should send only a 'replace with' command");
- }
- return this._super.apply(this, arguments);
- },
- });
-
- form.$('.o_data_row:first').click();
- assert.strictEqual($('.modal .modal-title').first().text().trim(), 'Open: one2many turtle field',
- "modal should use the python field string as title");
- $('.modal .o_form_button_cancel').click();
- form.$buttons.find('.o_form_button_edit').click();
-
- // edit the first one2many record
- form.$('.o_data_row:first').click();
- var $many2one = $('.modal [name="product_id"] input').click();
- var $dropdown = $many2one.autocomplete('widget');
- $dropdown.find('li:first a').mouseenter();
- $dropdown.find('li:first a').click();
- $('.modal-footer button:first').click();
-
- $('.o_form_button_save').click(); // don't save anything because the one2many does not change
-
- // add a one2many record
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_field_x2many_list_row_add a').click();
- $('.modal .o_data_row:first .o_list_record_selector input').click();
- $('.modal .o_select_button').click();
-
- // edit the second one2many record
- form.$('.o_data_row:eq(1)').click();
- $many2one = $('.modal [name="product_id"] input').click();
- $dropdown = $many2one.autocomplete('widget');
- $dropdown.find('li:first a').mouseenter();
- $dropdown.find('li:first a').click();
- $('.modal-footer button:first').click();
-
- $('.o_form_button_save').click();
-
- form.destroy();
- });
-
- QUnit.test('new record, the context is properly evaluated and sent', function (assert) {
- assert.expect(2);
-
- this.data.partner.fields.int_field.default = 17;
- var n = 0;
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="int_field"/>' +
- '<field name="p" context="{\'hello\': \'world\', \'abc\': int_field}">' +
- '<tree editable="top">' +
- '<field name="foo"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- mockRPC: function (route, args) {
- if (args.method === 'default_get') {
- n++;
- if (n === 2) {
- var context = args.kwargs.context;
- assert.strictEqual(context.hello, "world");
- assert.strictEqual(context.abc, 17);
- }
- }
- return this._super.apply(this, arguments);
- },
- });
-
- form.$buttons.find('.o_form_button_edit').click();
- form.$('tbody td.o_field_x2many_list_row_add a').click();
- form.destroy();
- });
-
- QUnit.test('parent data is properly sent on an onchange rpc', function (assert) {
- assert.expect(1);
-
- this.data.partner.onchanges = {bar: function () {}};
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="foo"/>' +
- '<field name="p">' +
- '<tree editable="top">' +
- '<field name="bar"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- mockRPC: function (route, args) {
- if (args.method === 'onchange') {
- var fieldValues = args.args[1];
- assert.strictEqual(fieldValues.trululu.foo, "yop",
- "should have properly sent the parent foo value");
- }
- return this._super.apply(this, arguments);
- },
- });
-
- form.$buttons.find('.o_form_button_edit').click();
- form.$('tbody td.o_field_x2many_list_row_add a').click();
- form.destroy();
- });
-
- QUnit.test('parent data is properly sent on an onchange rpc, new record', function (assert) {
- assert.expect(6);
-
- this.data.turtle.onchanges = {turtle_bar: function () {}};
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="foo"/>' +
- '<field name="turtles">' +
- '<tree editable="top">' +
- '<field name="turtle_bar"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- mockRPC: function (route, args) {
- assert.step(args.method);
- if (args.method === 'onchange' && args.model === 'turtle') {
- var fieldValues = args.args[1];
- assert.strictEqual(fieldValues.turtle_trululu.foo, "My little Foo Value",
- "should have properly sent the parent foo value");
- }
- return this._super.apply(this, arguments);
- },
- });
-
- form.$buttons.find('.o_form_button_edit').click();
- form.$('tbody td.o_field_x2many_list_row_add a').click();
- assert.verifySteps(['default_get', 'onchange', 'default_get', 'onchange']);
- form.destroy();
- });
-
- QUnit.test('id in one2many obtained in onchange is properly set', function (assert) {
- assert.expect(1);
-
- this.data.partner.onchanges.turtles = function (obj) {
- obj.turtles = [
- [5],
- [1, 3, {turtle_foo: "kawa"}]
- ];
- };
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="turtles">' +
- '<tree>' +
- '<field name="id"/>' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- });
-
- assert.strictEqual(form.$('tr.o_data_row').text(), '3kawa',
- "should have properly displayed id and foo field");
- form.destroy();
- });
-
- QUnit.test('id field in one2many in a new record', function (assert) {
- assert.expect(1);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="turtles">' +
- '<tree editable="bottom">' +
- '<field name="id" invisible="1"/>' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- mockRPC: function (route, args) {
- if (args.method === 'create') {
- var virtualID = args.args[0].turtles[0][1];
- assert.deepEqual(args.args[0].turtles,
- [[0, virtualID, {turtle_foo: "cat"}]],
- 'should send proper commands');
- }
- return this._super.apply(this, arguments);
- },
- });
- form.$('td.o_field_x2many_list_row_add a').click();
- form.$('td input[name="turtle_foo"]').val('cat').trigger('input');
- form.$buttons.find('.o_form_button_save').click();
-
- form.destroy();
- });
-
- QUnit.test('sub form view with a required field', function (assert) {
- assert.expect(2);
- this.data.partner.fields.foo.required = true;
- this.data.partner.fields.foo.default = null;
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="p">' +
- '<form string="Partner">' +
- '<group><field name="foo"/></group>' +
- '</form>' +
- '<tree>' +
- '<field name="foo"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- });
-
- form.$buttons.find('.o_form_button_edit').click();
- form.$('tbody td.o_field_x2many_list_row_add a').click();
- $('.modal-footer button.btn-primary').first().click();
-
- assert.strictEqual($('.modal').length, 1, "should still have an open modal");
- assert.strictEqual($('.modal tbody label.o_field_invalid').length, 1,
- "should have displayed invalid fields");
- form.destroy();
- });
-
- QUnit.test('one2many list with action button', function (assert) {
- assert.expect(4);
-
- this.data.partner.records[0].p = [2];
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="int_field"/>' +
- '<field name="p">' +
- '<tree>' +
- '<field name="foo"/>' +
- '<button name="method_name" type="object" icon="fa-plus"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- intercepts: {
- execute_action: function (event) {
- assert.deepEqual(event.data.env.currentID, 2,
- 'should call with correct id');
- assert.strictEqual(event.data.env.model, 'partner',
- 'should call with correct model');
- assert.strictEqual(event.data.action_data.name, 'method_name',
- "should call correct method");
- assert.strictEqual(event.data.action_data.type, 'object',
- 'should have correct type');
- },
- },
- });
-
- form.$('.o_list_button button').click();
-
- form.destroy();
- });
-
- QUnit.test('one2many kanban with action button', function (assert) {
- assert.expect(4);
-
- this.data.partner.records[0].p = [2];
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="p">' +
- '<kanban>' +
- '<field name="foo"/>' +
- '<templates>' +
- '<t t-name="kanban-box">' +
- '<div>' +
- '<span><t t-esc="record.foo.value"/></span>' +
- '<button name="method_name" type="object" class="fa fa-plus"/>' +
- '</div>' +
- '</t>' +
- '</templates>' +
- '</kanban>' +
- '</field>' +
- '</form>',
- res_id: 1,
- intercepts: {
- execute_action: function (event) {
- assert.deepEqual(event.data.env.currentID, 2,
- 'should call with correct id');
- assert.strictEqual(event.data.env.model, 'partner',
- 'should call with correct model');
- assert.strictEqual(event.data.action_data.name, 'method_name',
- "should call correct method");
- assert.strictEqual(event.data.action_data.type, 'object',
- 'should have correct type');
- },
- },
- });
-
- form.$('.oe_kanban_action_button').click();
-
- form.destroy();
- });
-
- QUnit.test('one2many kanban with edit type action and domain widget (widget using SpecialData)', function (assert) {
- assert.expect(1);
-
- this.data.turtle.fields.model_name = {string: "Domain Condition Model", type: "char"};
- this.data.turtle.fields.condition = {string: "Domain Condition", type: "char"};
- _.each(this.data.turtle.records, function (record) {
- record.model_name = 'partner';
- record.condition = '[]';
- });
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<group>' +
- '<field name="turtles" mode="kanban">' +
- '<kanban>' +
- '<templates>' +
- '<t t-name="kanban-box">' +
- '<div><field name="display_name"/></div>' +
- '<div><field name="turtle_foo"/></div>' +
- // field without Widget in the list
- '<div><field name="condition"/></div>' +
- '<div> <a type="edit"> Edit </a> </div>' +
- '</t>' +
- '</templates>' +
- '</kanban>' +
- '<form>' +
- '<field name="product_id" widget="statusbar"/>' +
- '<field name="model_name"/>' +
- // field with Widget requiring specialData in the form
- '<field name="condition" widget="domain" options="{\'model\': \'model_name\'}"/>' +
- '</form>' +
- '</field>' +
- '</group>' +
- '</form>',
- res_id: 1,
- });
-
- form.$('.oe_kanban_action:eq(0)').click();
- assert.strictEqual($('.o_domain_selector').length, 1, "should add domain selector widget");
- form.destroy();
- });
-
- QUnit.test('one2many list with onchange and domain widget (widget using SpecialData)', function (assert) {
- assert.expect(3);
-
- this.data.turtle.fields.model_name = {string: "Domain Condition Model", type: "char"};
- this.data.turtle.fields.condition = {string: "Domain Condition", type: "char"};
- _.each(this.data.turtle.records, function (record) {
- record.model_name = 'partner';
- record.condition = '[]';
- });
- this.data.partner.onchanges = {
- turtles: function (obj) {
- var virtualID = obj.turtles[1][1];
- obj.turtles = [
- [5], // delete all
- [0, virtualID, {
- display_name: "coucou",
- product_id: [37, "xphone"],
- turtle_bar: false,
- turtle_foo: "has changed",
- turtle_int: 42,
- turtle_qux: 9.8,
- partner_ids: [],
- turtle_ref: 'product,37',
- model_name: 'partner',
- condition: '[]',
- }],
- ];
- },
- };
- var nbFetchSpecialDomain = 0;
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<group>' +
- '<field name="turtles" mode="tree">' +
- '<tree>' +
- '<field name="display_name"/>' +
- '<field name="turtle_foo"/>' +
- // field without Widget in the list
- '<field name="condition"/>' +
- '</tree>' +
- '<form>' +
- '<field name="model_name"/>' +
- // field with Widget requiring specialData in the form
- '<field name="condition" widget="domain" options="{\'model\': \'model_name\'}"/>' +
- '</form>' +
- '</field>' +
- '</group>' +
- '</form>',
- res_id: 1,
- viewOptions: {
- mode: 'edit',
- },
- mockRPC: function (route) {
- if (route === '/web/dataset/call_kw/partner/search_count') {
- nbFetchSpecialDomain++;
- }
- return this._super.apply(this, arguments);
- }
- });
-
- form.$('.o_field_one2many .o_field_x2many_list_row_add a').click();
- assert.strictEqual($('.modal').length, 1, "form view dialog should be opened");
- $('.modal-body input[name="model_name"]').val('partner').trigger('input');
- $('.modal-footer button:first').click();
-
- assert.strictEqual(form.$('.o_field_one2many tbody tr:first').text(), "coucouhas changed[]",
- "the onchange should create one new record and remove the existing");
-
- form.$('.o_field_one2many .o_list_view tbody tr:eq(0) td:first').click();
-
- form.$buttons.find('.o_form_button_save').click();
- assert.strictEqual(nbFetchSpecialDomain, 1,
- "should only fetch special domain once");
- form.destroy();
- });
-
- QUnit.test('one2many without inline tree arch', function (assert) {
- assert.expect(2);
-
- this.data.partner.records[0].turtles = [2,3];
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<group>' +
- '<field name="p" widget="many2many_tags"/>' + // check if the view don not call load view (widget without useSubview)
- '<field name="turtles"/>' +
- '<field name="timmy" invisible="1"/>' + // check if the view don not call load view in invisible
- '</group>' +
- '</form>',
- res_id: 1,
- archs: {
- "turtle,false,list": '<tree string="Turtles"><field name="turtle_bar"/><field name="display_name"/><field name="partner_ids"/></tree>',
- }
- });
-
- assert.strictEqual(form.$('.o_field_widget[name="turtles"] .o_list_view').length, 1,
- 'should display one2many list view in the modal');
-
- assert.strictEqual(form.$('.o_data_row').length, 2,
- 'should display the 2 turtles');
-
- form.destroy();
- });
-
- QUnit.test('many2one and many2many in one2many', function (assert) {
- assert.expect(11);
-
- this.data.turtle.records[1].product_id = 37;
- this.data.partner.records[0].turtles = [2, 3];
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<group>' +
- '<field name="int_field"/>' +
- '<field name="turtles">' +
- '<form string="Turtles">' +
- '<group>' +
- '<field name="product_id"/>' +
- '</group>' +
- '</form>' +
- '<tree editable="top">' +
- '<field name="display_name"/>' +
- '<field name="product_id"/>' +
- '<field name="partner_ids" widget="many2many_tags"/>' +
- '</tree>' +
- '</field>' +
- '</group>' +
- '</form>',
- res_id: 1,
- mockRPC: function (route, args) {
- if (args.method === 'write') {
- var commands = args.args[1].turtles;
- assert.strictEqual(commands.length, 2,
- "should have generated 2 commands");
- assert.deepEqual(commands[0], [1, 2, {
- partner_ids: [[6, false, [2, 1]]],
- product_id: 41,
- }], "generated commands should be correct");
- assert.deepEqual(commands[1], [4, 3, false],
- "generated commands should be correct");
- }
- return this._super.apply(this, arguments);
- },
- });
-
- assert.strictEqual(form.$('.o_data_row').length, 2,
- 'should display the 2 turtles');
- assert.strictEqual(form.$('.o_data_row:first td:nth(1)').text(), 'xphone',
- "should correctly display the m2o");
- assert.strictEqual(form.$('.o_data_row:first td:nth(2) .badge').length, 2,
- "m2m should contain two tags");
- assert.strictEqual(form.$('.o_data_row:first td:nth(2) .badge:first span').text(),
- 'second record', "m2m values should have been correctly fetched");
-
- form.$('.o_data_row:first').click();
-
- assert.strictEqual($('.modal .o_field_widget').text(), "xphone",
- 'should display the form view dialog with the many2one value');
- $('.modal-footer button').click(); // close the modal
-
- form.$buttons.find('.o_form_button_edit').click();
-
- // edit the m2m of first row
- form.$('.o_list_view tbody td:first()').click();
- // remove a tag
- form.$('.o_field_many2manytags .badge:contains(aaa) .o_delete').click();
- assert.strictEqual(form.$('.o_selected_row .o_field_many2manytags .o_badge_text:contains(aaa)').length, 0,
- "tag should have been correctly removed");
- // add a tag
- var $m2mInput = form.$('.o_selected_row .o_field_many2manytags input');
- $m2mInput.click();
- $m2mInput.autocomplete('widget').find('li:first()').click();
- assert.strictEqual(form.$('.o_selected_row .o_field_many2manytags .o_badge_text:contains(first record)').length, 1,
- "tag should have been correctly added");
-
- // edit the m2o of first row
- var $m2oInput = form.$('.o_selected_row .o_field_many2one:first input');
- $m2oInput.click();
- $m2oInput.autocomplete('widget').find('li:contains(xpad)').mouseover().click();
- assert.strictEqual(form.$('.o_selected_row .o_field_many2one:first input').val(), 'xpad',
- "m2o value should have been updated");
-
- // save (should correctly generate the commands)
- form.$buttons.find('.o_form_button_save').click();
-
- form.destroy();
- });
-
- QUnit.test('many2manytag in one2many, onchange, some modifiers, and more than one page', function (assert) {
- assert.expect(9);
-
- this.data.partner.records[0].turtles = [1,2,3];
-
- this.data.partner.onchanges.turtles = function () {};
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="turtles">' +
- '<tree editable="top" limit="2">' +
- '<field name="turtle_foo"/>' +
- '<field name="partner_ids" widget="many2many_tags" attrs="{\'readonly\': [(\'turtle_foo\', \'=\', \'a\')]}"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- viewOptions: {mode: 'edit'},
- mockRPC: function (route, args) {
- assert.step(args.method);
- return this._super.apply(this, arguments);
- },
- });
- assert.strictEqual(form.$('.o_data_row').length, 2,
- 'there should be only 2 rows displayed');
- form.$('.o_list_record_remove').click();
- form.$('.o_list_record_remove').click();
-
- assert.strictEqual(form.$('.o_data_row').length, 1,
- 'there should be just one remaining row');
-
- assert.verifySteps([
- "read", // initial read on partner
- "read", // initial read on turtle
- "read", // batched read on partner (field partner_ids)
- "read", // after first delete, read on turtle (to fetch 3rd record)
- "onchange", // after first delete, onchange on field turtles
- "onchange" // onchange after second delete
- ]);
-
- form.destroy();
- });
-
- QUnit.test('onchange many2many in one2many list editable', function (assert) {
- assert.expect(14);
-
- this.data.product.records.push({
- id: 1,
- display_name: "xenomorphe",
- });
-
- this.data.turtle.onchanges = {
- product_id: function (rec) {
- if (rec.product_id) {
- rec.partner_ids = [
- [5],
- [4, rec.product_id === 41 ? 1 : 2]
- ];
- }
- },
- };
- var partnerOnchange = function (rec) {
- if (!rec.int_field || !rec.turtles.length) {
- return;
- }
- rec.turtles = [
- [5],
- [0, 0, {
- display_name: 'new line',
- product_id: [37, 'xphone'],
- partner_ids: [
- [5],
- [4, 1]
- ]
- }],
- [0, rec.turtles[0][1], {
- display_name: rec.turtles[0][2].display_name,
- product_id: [1, 'xenomorphe'],
- partner_ids: [
- [5],
- [4, 2]
- ]
- }],
- ];
- };
-
- this.data.partner.onchanges = {
- int_field: partnerOnchange,
- turtles: partnerOnchange,
- };
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<group>' +
- '<field name="int_field"/>' +
- '<field name="turtles">' +
- '<tree editable="bottom">' +
- '<field name="display_name"/>' +
- '<field name="product_id"/>' +
- '<field name="partner_ids" widget="many2many_tags"/>' +
- '</tree>' +
- '</field>' +
- '</group>' +
- '</form>',
- });
-
- // add new line (first, xpad)
- form.$('.o_field_x2many_list_row_add a').click();
- form.$('input[name="display_name"]').val('first').trigger('input');
- form.$('div[name="product_id"] input').click();
- // the onchange won't be generated
- $('li.ui-menu-item a:contains(xpad)').trigger('mouseenter').click();
-
- assert.strictEqual(form.$('.o_field_many2manytags.o_input').length, 1,
- 'should display the line in editable mode');
- assert.strictEqual(form.$('.o_field_many2one input').val(), "xpad",
- 'should display the product xpad');
- assert.strictEqual(form.$('.o_field_many2manytags.o_input .o_badge_text').text(), "first record",
- 'should display the tag from the onchange');
-
- form.$('input.o_field_integer[name="int_field"]').click();
-
- assert.strictEqual(form.$('.o_data_cell.o_required_modifier').text(), "xpad",
- 'should display the product xpad');
- assert.strictEqual(form.$('.o_field_many2manytags:not(.o_input) .o_badge_text').text(), "first record",
- 'should display the tag in readonly');
-
- // enable the many2many onchange and generate it
- form.$('input.o_field_integer[name="int_field"]').val('10').trigger('input');
-
- assert.strictEqual(form.$('.o_data_cell.o_required_modifier').text(), "xenomorphexphone",
- 'should display the product xphone and xenomorphe');
- assert.strictEqual(form.$('.o_data_row').text().replace(/\s+/g, ' '), "firstxenomorphe second record new linexphone first record ",
- 'should display the name, one2many and many2many value');
-
- // disable the many2many onchange
- form.$('input.o_field_integer[name="int_field"]').val('0').trigger('input');
-
- // remove and start over
- form.$('.o_list_record_remove:first button').click();
- form.$('.o_list_record_remove:first button').click();
-
- // enable the many2many onchange
- form.$('input.o_field_integer[name="int_field"]').val('10').trigger('input');
-
- // add new line (first, xenomorphe)
- form.$('.o_field_x2many_list_row_add a').click();
- form.$('input[name="display_name"]').val('first').trigger('input');
- form.$('div[name="product_id"] input').click();
- // generate the onchange
- $('li.ui-menu-item a:contains(xenomorphe)').trigger('mouseenter').click();
-
- assert.strictEqual(form.$('.o_field_many2manytags.o_input').length, 1,
- 'should display the line in editable mode');
- assert.strictEqual(form.$('.o_field_many2one input').val(), "xenomorphe",
- 'should display the product xenomorphe');
- assert.strictEqual(form.$('.o_field_many2manytags.o_input .o_badge_text').text(), "second record",
- 'should display the tag from the onchange');
-
- // put list in readonly mode
- form.$('input.o_field_integer[name="int_field"]').click();
-
- assert.strictEqual(form.$('.o_data_cell.o_required_modifier').text(), "xenomorphexphone",
- 'should display the product xphone and xenomorphe');
- assert.strictEqual(form.$('.o_field_many2manytags:not(.o_input) .o_badge_text').text(), "second recordfirst record",
- 'should display the tag in readonly (first record and second record)');
-
- form.$('input.o_field_integer[name="int_field"]').val('10').trigger('input');
-
- assert.strictEqual(form.$('.o_data_row').text().replace(/\s+/g, ' '), "firstxenomorphe second record new linexphone first record ",
- 'should display the name, one2many and many2many value');
-
- form.$buttons.find('.o_form_button_save').click();
-
- assert.strictEqual(form.$('.o_data_row').text().replace(/\s+/g, ' '), "firstxenomorphe second record new linexphone first record ",
- 'should display the name, one2many and many2many value after save');
-
- form.destroy();
- });
-
- QUnit.test('load view for x2many in one2many', function (assert) {
- assert.expect(2);
-
- this.data.turtle.records[1].product_id = 37;
- this.data.partner.records[0].turtles = [2,3];
- this.data.partner.records[2].turtles = [1,3];
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<group>' +
- '<field name="int_field"/>' +
- '<field name="turtles">' +
- '<form string="Turtles">' +
- '<group>' +
- '<field name="product_id"/>' +
- '<field name="partner_ids"/>' +
- '</group>' +
- '</form>' +
- '<tree>' +
- '<field name="display_name"/>' +
- '</tree>' +
- '</field>' +
- '</group>' +
- '</form>',
- res_id: 1,
- archs: {
- "partner,false,list": '<tree string="Partners"><field name="display_name"/></tree>',
- },
- });
-
- assert.strictEqual(form.$('.o_data_row').length, 2,
- 'should display the 2 turtles');
-
- form.$('.o_data_row:first').click();
-
- assert.strictEqual($('.modal .o_field_widget[name="partner_ids"] .o_list_view').length, 1,
- 'should display many2many list view in the modal');
-
- form.destroy();
- });
-
- QUnit.test('one2many (who contains a one2many) with tree view and without form view', function (assert) {
- assert.expect(1);
-
- // avoid error in _postprocess
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<group>' +
- '<field name="turtles">' +
- '<tree>' +
- '<field name="partner_ids"/>' +
- '</tree>' +
- '</field>' +
- '</group>' +
- '</form>',
- res_id: 1,
- archs: {
- "turtle,false,form": '<form string="Turtles"><field name="turtle_foo"/></form>',
- },
- });
-
- form.$('.o_data_row:first').click();
-
- assert.strictEqual($('.modal .o_field_widget[name="turtle_foo"]').text(), 'blip',
- 'should open the modal and display the form field');
-
- form.destroy();
- });
-
- QUnit.test('one2many with x2many in form view (but not in list view)', function (assert) {
- assert.expect(1);
-
- // avoid error when saving the edited related record (because the
- // related x2m field is unknown in the inline list view)
- // also ensure that the changes are correctly saved
-
- this.data.turtle.fields.o2m = {string: "o2m", type: "one2many", relation: 'user'};
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<group>' +
- '<field name="turtles">' +
- '<tree>' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '</field>' +
- '</group>' +
- '</form>',
- res_id: 1,
- archs: {
- "turtle,false,form": '<form string="Turtles">' +
- '<field name="partner_ids" widget="many2many_tags"/>' +
- '</form>',
- },
- viewOptions: {
- mode: 'edit',
- },
- mockRPC: function (route, args) {
- if (args.method === 'write') {
- assert.deepEqual(args.args[1].turtles, [[1, 2, {
- partner_ids: [[6, false, [2, 4, 1]]],
- }]]);
- }
- return this._super.apply(this, arguments);
- },
- });
-
- form.$('.o_data_row:first').click(); // edit first record
-
- var $input = $('.modal .o_field_many2manytags input');
- $input.click(); // opens the dropdown
- $input.autocomplete('widget').find('li').click(); // add 'first record'
-
- // add a many2many tag and save
- $('.modal .o_field_x2many_list_row_add a').click();
- $('.modal .o_field_widget[name=name]').val('test').trigger('input');
- $('.modal .modal-footer .btn-primary').click(); // save
-
- form.$buttons.find('.o_form_button_save').click();
-
- form.destroy();
- });
-
- QUnit.test('many2many list in a one2many opened by a many2one', function (assert) {
- assert.expect(1);
-
- this.data.turtle.records[1].turtle_trululu = 2;
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="turtles">' +
- '<tree editable="bottom">' +
- '<field name="turtle_trululu"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- archs: {
- "partner,false,form": '<form string="P">' +
- '<field name="timmy"/>' +
- '</form>',
- "partner_type,false,list": '<tree editable="bottom">' +
- '<field name="display_name"/>' +
- '</tree>',
- "partner_type,false,search": '<search>' +
- '</search>',
- },
- viewOptions: {
- mode: 'edit',
- },
- mockRPC: function (route, args) {
- if (route === '/web/dataset/call_kw/partner/get_formview_id') {
- return $.when(false);
- }
- if (args.method === 'write') {
- assert.deepEqual(args.args[1].timmy, [[6, false, [12]]],
- 'should properly write ids');
- }
- return this._super.apply(this, arguments);
- },
- });
-
- // edit the first partner in the one2many partner form view
- form.$('.o_data_row:first td.o_data_cell').click();
- // open form view for many2one
- form.$('.o_external_button').click();
-
- // click on add, to add a new partner in the m2m
- $('.modal .o_field_x2many_list_row_add a').click();
-
- // select the partner_type 'gold' (this closes the 2nd modal)
- $('.modal td:contains(gold)').click();
-
- // confirm the changes in the modal
- $('.modal .modal-footer .btn-primary').click();
-
- form.$buttons.find('.o_form_button_save').click();
- form.destroy();
- });
-
- QUnit.test('nested x2many default values', function (assert) {
- assert.expect(3);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="turtles">' +
- '<tree editable="top">' +
- '<field name="partner_ids" widget="many2many_tags"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- mockRPC: function (route, args) {
- if (args.model === 'partner' && args.method === 'default_get') {
- return $.when({
- turtles: [
- [0, 0, {partner_ids: [[6, 0, [4]]]}],
- [0, 0, {partner_ids: [[6, 0, [1]]]}],
- ],
- });
- }
- return this._super.apply(this, arguments);
- },
- });
-
- assert.strictEqual(form.$('.o_list_view .o_data_row').length, 2,
- "one2many list should contain 2 rows");
- assert.strictEqual(form.$('.o_list_view .o_field_many2manytags[name="partner_ids"] .badge').length, 2,
- "m2mtags should contain two tags");
- assert.strictEqual(form.$('.o_list_view .o_field_many2manytags[name="partner_ids"] .o_badge_text').text(),
- 'aaafirst record', "tag names should have been correctly loaded");
-
- form.destroy();
- });
-
- QUnit.test('one2many (who contains display_name) with tree view and without form view', function (assert) {
- assert.expect(1);
-
- // avoid error in _fetchX2Manys
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<group>' +
- '<field name="turtles">' +
- '<tree>' +
- '<field name="display_name"/>' +
- '</tree>' +
- '</field>' +
- '</group>' +
- '</form>',
- res_id: 1,
- archs: {
- "turtle,false,form": '<form string="Turtles"><field name="turtle_foo"/></form>',
- },
- });
-
- form.$('.o_data_row:first').click();
-
- assert.strictEqual($('.modal .o_field_widget[name="turtle_foo"]').text(), 'blip',
- 'should open the modal and display the form field');
-
- form.destroy();
- });
-
- QUnit.test('one2many field with virtual ids', function (assert) {
- assert.expect(11);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<sheet>' +
- '<group>' +
- '<notebook>' +
- '<page>' +
- '<field name="p" mode="kanban">' +
- '<kanban>' +
- '<templates>' +
- '<t t-name="kanban-box">' +
- '<div class="oe_kanban_details">' +
- '<div class="o_test_id">' +
- '<field name="id"/>' +
- '</div>' +
- '<div class="o_test_foo">' +
- '<field name="foo"/>' +
- '</div>' +
- '</div>' +
- '</t>' +
- '</templates>' +
- '</kanban>' +
- '</field>' +
- '</page>' +
- '</notebook>' +
- '</group>' +
- '</sheet>' +
- '</form>',
- archs: {
- 'partner,false,form': '<form string="Associated partners">' +
- '<field name="foo"/>' +
- '</form>',
- },
- res_id: 4,
- });
-
- assert.strictEqual(form.$('.o_field_widget .o_kanban_view').length, 1,
- "should have one inner kanban view for the one2many field");
- assert.strictEqual(form.$('.o_field_widget .o_kanban_view .o_kanban_record:not(.o_kanban_ghost)').length, 0,
- "should not have kanban records yet");
-
- // // switch to edit mode and create a new kanban record
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_field_widget .o-kanban-button-new').click();
-
- // save & close the modal
- assert.strictEqual($('.modal-content input.o_field_widget').val(), 'My little Foo Value',
- "should already have the default value for field foo");
- $('.modal-content .btn-primary').first().click();
-
- assert.strictEqual(form.$('.o_field_widget .o_kanban_view').length, 1,
- "should have one inner kanban view for the one2many field");
- assert.strictEqual(form.$('.o_field_widget .o_kanban_view .o_kanban_record:not(.o_kanban_ghost)').length, 1,
- "should now have one kanban record");
- assert.strictEqual(form.$('.o_field_widget .o_kanban_view .o_kanban_record:not(.o_kanban_ghost) .o_test_id').text(),
- '', "should not have a value for the id field");
- assert.strictEqual(form.$('.o_field_widget .o_kanban_view .o_kanban_record:not(.o_kanban_ghost) .o_test_foo').text(),
- 'My little Foo Value', "should have a value for the foo field");
-
- // save the view to force a create of the new record in the one2many
- form.$buttons.find('.o_form_button_save').click();
- assert.strictEqual(form.$('.o_field_widget .o_kanban_view').length, 1,
- "should have one inner kanban view for the one2many field");
- assert.strictEqual(form.$('.o_field_widget .o_kanban_view .o_kanban_record:not(.o_kanban_ghost)').length, 1,
- "should now have one kanban record");
- assert.notEqual(form.$('.o_field_widget .o_kanban_view .o_kanban_record:not(.o_kanban_ghost) .o_test_id').text(),
- '', "should now have a value for the id field");
- assert.strictEqual(form.$('.o_field_widget .o_kanban_view .o_kanban_record:not(.o_kanban_ghost) .o_test_foo').text(),
- 'My little Foo Value', "should still have a value for the foo field");
-
- form.destroy();
- });
-
- QUnit.test('focusing fields in one2many list', function (assert) {
- assert.expect(2);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<group>' +
- '<field name="turtles">' +
- '<tree editable="top">' +
- '<field name="turtle_foo"/>' +
- '<field name="turtle_int"/>' +
- '</tree>' +
- '</field>' +
- '</group>' +
- '<field name="foo"/>' +
- '</form>',
- res_id: 1,
- });
- form.$buttons.find('.o_form_button_edit').click();
-
- form.$('.o_data_row:first td:first').click();
- assert.strictEqual(form.$('input[name="turtle_foo"]')[0], document.activeElement,
- "turtle foo field should have focus");
-
- form.$('input[name="turtle_foo"]').trigger({type: 'keydown', which: $.ui.keyCode.TAB});
- assert.strictEqual(form.$('input[name="turtle_int"]')[0], document.activeElement,
- "turtle int field should have focus");
- form.destroy();
- });
-
- QUnit.test('one2many list editable = top', function (assert) {
- assert.expect(6);
-
- this.data.turtle.fields.turtle_foo.default = "default foo turtle";
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<group>' +
- '<field name="turtles">' +
- '<tree editable="top">' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '</field>' +
- '</group>' +
- '</form>',
- res_id: 1,
- mockRPC: function (route, args) {
- if (args.method === 'write') {
- var commands = args.args[1].turtles;
- assert.strictEqual(commands[0][0], 0,
- "first command is a create");
- assert.strictEqual(commands[1][0], 4,
- "second command is a link to");
- }
- return this._super.apply(this, arguments);
- },
- });
- form.$buttons.find('.o_form_button_edit').click();
-
- assert.strictEqual(form.$('.o_data_row').length, 1,
- "should start with one data row");
-
- form.$('.o_field_x2many_list_row_add a').click();
-
- assert.strictEqual(form.$('.o_data_row').length, 2,
- "should have 2 data rows");
- assert.strictEqual(form.$('tr.o_data_row:first input').val(), 'default foo turtle',
- "first row should be the new value");
- assert.ok(form.$('tr.o_data_row:first').hasClass('o_selected_row'),
- "first row should be selected");
-
- form.$buttons.find('.o_form_button_save').click();
- form.destroy();
- });
-
- QUnit.test('one2many list editable = bottom', function (assert) {
- assert.expect(6);
- this.data.turtle.fields.turtle_foo.default = "default foo turtle";
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<group>' +
- '<field name="turtles">' +
- '<tree editable="bottom">' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '</field>' +
- '</group>' +
- '</form>',
- res_id: 1,
- mockRPC: function (route, args) {
- if (args.method === 'write') {
- var commands = args.args[1].turtles;
- assert.strictEqual(commands[0][0], 4,
- "first command is a link to");
- assert.strictEqual(commands[1][0], 0,
- "second command is a create");
- }
- return this._super.apply(this, arguments);
- },
- });
- form.$buttons.find('.o_form_button_edit').click();
-
- assert.strictEqual(form.$('.o_data_row').length, 1,
- "should start with one data row");
-
- form.$('.o_field_x2many_list_row_add a').click();
-
- assert.strictEqual(form.$('.o_data_row').length, 2,
- "should have 2 data rows");
- assert.strictEqual(form.$('tr.o_data_row:eq(1) input').val(), 'default foo turtle',
- "second row should be the new value");
- assert.ok(form.$('tr.o_data_row:eq(1)').hasClass('o_selected_row'),
- "second row should be selected");
-
- form.$buttons.find('.o_form_button_save').click();
- form.destroy();
- });
-
- QUnit.test('x2many fields use their "mode" attribute', function (assert) {
- assert.expect(1);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<group>' +
- '<field mode="kanban" name="turtles">' +
- '<tree>' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '<kanban>' +
- '<templates>' +
- '<t t-name="kanban-box">' +
- '<div>' +
- '<field name="turtle_int"/>' +
- '</div>' +
- '</t>' +
- '</templates>' +
- '</kanban>' +
- '</field>' +
- '</group>' +
- '</form>',
- res_id: 1,
- });
-
- assert.strictEqual(form.$('.o_field_one2many .o_kanban_view').length, 1,
- "should have rendered a kanban view");
-
- form.destroy();
- });
-
- QUnit.test('one2many list editable, no onchange when required field is not set', function (assert) {
- assert.expect(7);
-
- this.data.turtle.fields.turtle_foo.required = true;
- this.data.partner.onchanges = {
- turtles: function (obj) {
- obj.int_field = obj.turtles.length;
- },
- };
- this.data.partner.records[0].int_field = 0;
- this.data.partner.records[0].turtles = [];
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="int_field"/>' +
- '<field name="turtles">' +
- '<tree editable="top">' +
- '<field name="turtle_int"/>' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- mockRPC: function (route, args) {
- assert.step(args.method);
- return this._super.apply(this, arguments);
- },
- res_id: 1,
- });
- form.$buttons.find('.o_form_button_edit').click();
-
- assert.strictEqual(form.$('.o_field_widget[name="int_field"]').val(), "0",
- "int_field should start with value 0");
- form.$('.o_field_x2many_list_row_add a').click();
- assert.strictEqual(form.$('.o_field_widget[name="int_field"]').val(), "0",
- "int_field should still be 0 (no onchange should have been done yet");
-
- assert.verifySteps(['read', 'default_get'], "no onchange should have been applied");
-
- form.$('.o_field_widget[name="turtle_foo"]').val("some text").trigger('input');
- assert.strictEqual(form.$('.o_field_widget[name="int_field"]').val(), "1",
- "int_field should now be 1 (the onchange should have been done");
-
- form.destroy();
- });
-
- QUnit.test('one2many list editable: trigger onchange when row is valid', function (assert) {
- // should omit require fields that aren't in the view as they (obviously)
- // have no value, when checking the validity of required fields
- // shouldn't consider numerical fields with value 0 as unset
- assert.expect(12);
-
- this.data.turtle.fields.turtle_foo.required = true;
- this.data.turtle.fields.turtle_qux.required = true; // required field not in the view
- this.data.turtle.fields.turtle_bar.required = true; // required boolean field with no default
- delete this.data.turtle.fields.turtle_bar.default;
- this.data.turtle.fields.turtle_int.required = true; // required int field (default 0)
- this.data.turtle.fields.turtle_int.default = 0;
- this.data.turtle.fields.partner_ids.required = true; // required many2many
- this.data.partner.onchanges = {
- turtles: function (obj) {
- obj.int_field = obj.turtles.length;
- },
- };
- this.data.partner.records[0].int_field = 0;
- this.data.partner.records[0].turtles = [];
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="int_field"/>' +
- '<field name="turtles"/>' +
- '</form>',
- mockRPC: function (route, args) {
- assert.step(args.method);
- return this._super.apply(this, arguments);
- },
- archs: {
- 'turtle,false,list' : '<tree editable="top">' +
- '<field name="turtle_qux"/>' +
- '<field name="turtle_bar"/>' +
- '<field name="turtle_int"/>' +
- '<field name="turtle_foo"/>' +
- '<field name="partner_ids" widget="many2many_tags"/>' +
- '</tree>',
- },
- res_id: 1,
- });
- form.$buttons.find('.o_form_button_edit').click();
-
- assert.strictEqual(form.$('.o_field_widget[name="int_field"]').val(), "0",
- "int_field should start with value 0");
-
- // add a new row (which is invalid at first)
- form.$('.o_field_x2many_list_row_add a').click();
- assert.strictEqual(form.$('.o_field_widget[name="int_field"]').val(), "0",
- "int_field should still be 0 (no onchange should have been done yet)");
- assert.verifySteps(['load_views', 'read', 'default_get'], "no onchange should have been applied");
-
- // fill turtle_foo field
- form.$('.o_field_widget[name="turtle_foo"]').val("some text").trigger('input');
- assert.strictEqual(form.$('.o_field_widget[name="int_field"]').val(), "0",
- "int_field should still be 0 (no onchange should have been done yet)");
- assert.verifySteps(['load_views', 'read', 'default_get'], "no onchange should have been applied");
-
- // fill partner_ids field with a tag (all required fields will then be set)
- var $m2mInput = form.$('.o_field_widget[name=partner_ids] input');
- $m2mInput.click();
- $m2mInput.autocomplete('widget').find('li:first()').click();
- assert.strictEqual(form.$('.o_field_widget[name="int_field"]').val(), "1",
- "int_field should now be 1 (the onchange should have been done");
-
- form.destroy();
- });
-
- QUnit.test('one2many list editable: \'required\' modifiers is properly working', function (assert) {
- assert.expect(3);
-
- this.data.partner.onchanges = {
- turtles: function (obj) {
- obj.int_field = obj.turtles.length;
- },
- };
-
- this.data.partner.records[0].turtles = [];
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="int_field"/>' +
- '<field name="turtles">' +
- '<tree editable="top">' +
- '<field name="turtle_foo" required="1"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- });
- form.$buttons.find('.o_form_button_edit').click();
-
- assert.strictEqual(form.$('.o_field_widget[name="int_field"]').val(), "10",
- "int_field should start with value 10");
-
- form.$('.o_field_x2many_list_row_add a').click();
-
- assert.strictEqual(form.$('.o_field_widget[name="int_field"]').val(), "10",
- "int_field should still be 10 (no onchange, because line is not valid)");
-
- // fill turtle_foo field
- form.$('.o_field_widget[name="turtle_foo"]').val("some text").trigger('input');
-
- assert.strictEqual(form.$('.o_field_widget[name="int_field"]').val(), "1",
- "int_field should be 1 (onchange triggered, because line is now valid)");
-
- form.destroy();
- });
-
- QUnit.test('one2many list editable: \'required\' modifiers is properly working, part 2', function (assert) {
- assert.expect(3);
-
- this.data.partner.onchanges = {
- turtles: function (obj) {
- obj.int_field = obj.turtles.length;
- },
- };
-
- this.data.partner.records[0].turtles = [];
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="int_field"/>' +
- '<field name="turtles">' +
- '<tree editable="top">' +
- '<field name="turtle_int"/>' +
- '<field name="turtle_foo" attrs=\'{"required": [["turtle_int", "=", 0]]}\'/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- });
- form.$buttons.find('.o_form_button_edit').click();
-
- assert.strictEqual(form.$('.o_field_widget[name="int_field"]').val(), "10",
- "int_field should start with value 10");
-
- form.$('.o_field_x2many_list_row_add a').click();
-
- assert.strictEqual(form.$('.o_field_widget[name="int_field"]').val(), "10",
- "int_field should still be 10 (no onchange, because line is not valid)");
-
- // fill turtle_int field
- form.$('.o_field_widget[name="turtle_int"]').val("1").trigger('input');
-
- assert.strictEqual(form.$('.o_field_widget[name="int_field"]').val(), "1",
- "int_field should be 1 (onchange triggered, because line is now valid)");
-
- form.destroy();
- });
-
- QUnit.test('one2many list editable: add new line before onchange returns', function (assert) {
- // If the user adds a new row (with a required field with onchange), selects
- // a value for that field, then adds another row before the onchange returns,
- // the editable list must wait for the onchange to return before trying to
- // unselect the first row, otherwise it will be detected as invalid.
- assert.expect(7);
-
- this.data.turtle.onchanges = {
- turtle_trululu: function () {},
- };
-
- var def;
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="turtles">' +
- '<tree editable="bottom">' +
- '<field name="turtle_trululu" required="1"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- mockRPC: function (route, args) {
- var result = this._super.apply(this, arguments);
- if (args.method === 'onchange') {
- return $.when(def).then(_.constant(result));
- }
- return result;
- },
- });
-
- // add a first line but hold the onchange back
- form.$('.o_field_x2many_list_row_add a').click();
- assert.strictEqual(form.$('.o_data_row').length, 1,
- "should have created the first row immediately");
- def = $.Deferred();
- form.$('.o_field_many2one input').click();
- form.$('.o_field_many2one input').autocomplete('widget').find('a').first().click();
-
- // try to add a second line and check that it is correctly waiting
- // for the onchange to return
- form.$('.o_field_x2many_list_row_add a').click();
- assert.strictEqual($('.modal').length, 0, "no modal should be displayed");
- assert.strictEqual($('.o_field_invalid').length, 0,
- "no field should be marked as invalid");
- assert.strictEqual(form.$('.o_data_row').length, 1,
- "should wait for the onchange to create the second row");
- assert.ok(form.$('.o_data_row').hasClass('o_selected_row'),
- "first row should still be in edition");
-
- // resolve the onchange def
- def.resolve();
- assert.strictEqual(form.$('.o_data_row').length, 2,
- "second row should now have been created");
- assert.notOk(form.$('.o_data_row:first').hasClass('o_selected_row'),
- "first row should no more be in edition");
-
- form.destroy();
- });
-
- QUnit.test('editable list: multiple clicks on Add an item do not create invalid rows', function (assert) {
- assert.expect(3);
-
- this.data.turtle.onchanges = {
- turtle_trululu: function () {},
- };
-
- var def;
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="turtles">' +
- '<tree editable="bottom">' +
- '<field name="turtle_trululu" required="1"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- mockRPC: function (route, args) {
- var result = this._super.apply(this, arguments);
- if (args.method === 'onchange') {
- return $.when(def).then(_.constant(result));
- }
- return result;
- },
- });
-
- // click twice to add a new line
- def = $.Deferred();
- form.$('.o_field_x2many_list_row_add a').click();
- form.$('.o_field_x2many_list_row_add a').click();
- assert.strictEqual(form.$('.o_data_row').length, 0,
- "no row should have been created yet (waiting for the onchange)");
-
- // resolve the onchange def
- def.resolve();
- assert.strictEqual(form.$('.o_data_row').length, 1,
- "only one row should have been created");
- assert.ok(form.$('.o_data_row:first').hasClass('o_selected_row'),
- "the created row should be in edition");
-
- form.destroy();
- });
-
- QUnit.test('editable list: value reset by an onchange', function (assert) {
- // this test reproduces a subtle behavior that may occur in a form view:
- // the user adds a record in a one2many field, and directly clicks on a
- // datetime field of the form view which has an onchange, which totally
- // overrides the value of the one2many (commands 5 and 0). The handler
- // that switches the edited row to readonly is then called after the
- // new value of the one2many field is applied (the one returned by the
- // onchange), so the row that must go to readonly doesn't exist anymore.
- assert.expect(2);
-
- this.data.partner.onchanges = {
- datetime: function (obj) {
- obj.turtles = [[5], [0, 0, {display_name: 'new'}]];
- },
- };
-
- var def;
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="datetime"/>' +
- '<field name="turtles">' +
- '<tree editable="bottom">' +
- '<field name="display_name"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- mockRPC: function (route, args) {
- var result = this._super.apply(this, arguments);
- if (args.method === 'onchange') {
- return $.when(def).then(_.constant(result));
- }
- return result;
- },
- });
-
- // trigger the two onchanges
- form.$('.o_field_x2many_list_row_add a').click();
- form.$('.o_data_row .o_field_widget').val('a name').trigger('input');
- def = $.Deferred();
- form.$('.o_datepicker_input').click(); // focusout o2m and set value to today
- var dateTimeVal = fieldUtils.format.datetime(moment(), {timezone: false});
- form.$('.o_datepicker_input').val(dateTimeVal).trigger('change');
-
- // resolve the onchange def
- def.resolve();
-
- assert.strictEqual(form.$('.o_data_row').length, 1,
- "should have one record in the o2m");
- assert.strictEqual(form.$('.o_data_row .o_data_cell').text(), 'new',
- "should be the record created by the onchange");
-
- form.destroy();
- });
-
- QUnit.test('editable list: onchange that returns a warning', function (assert) {
- assert.expect(5);
-
- this.data.turtle.onchanges = {
- display_name: function () {},
- };
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="turtles">' +
- '<tree editable="bottom">' +
- '<field name="display_name"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- mockRPC: function (route, args) {
- if (args.method === 'onchange') {
- assert.step(args.method);
- return $.when({
- value: {},
- warning: {
- title: "Warning",
- message: "You must first select a partner"
- },
- });
- }
- return this._super.apply(this, arguments);
- },
- viewOptions: {
- mode: 'edit',
- },
- intercepts: {
- warning: function () {
- assert.step('warning');
- },
- },
- });
-
- // add a line (this should trigger an onchange and a warning)
- form.$('.o_field_x2many_list_row_add a').click();
-
- // check if 'Add an item' still works (this should trigger an onchange
- // and a warning again)
- form.$('.o_field_x2many_list_row_add a').click();
-
- assert.verifySteps(['onchange', 'warning', 'onchange', 'warning']);
-
- form.destroy();
- });
-
- QUnit.test('editable list: contexts are correctly sent', function (assert) {
- assert.expect(5);
-
- this.data.partner.records[0].timmy = [12];
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form>' +
- '<field name="foo"/>' +
- '<field name="timmy" context="{\'key\': parent.foo}">' +
- '<tree editable="top">' +
- '<field name="display_name"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- mockRPC: function (route, args) {
- if (args.method === 'read' && args.model === 'partner') {
- assert.deepEqual(args.kwargs.context, {
- active_field: 2,
- bin_size: true,
- someKey: 'some value',
- }, "sent context should be correct");
- }
- if (args.method === 'read' && args.model === 'partner_type') {
- assert.deepEqual(args.kwargs.context, {
- key: 'yop',
- active_field: 2,
- someKey: 'some value',
- }, "sent context should be correct");
- }
- if (args.method === 'write') {
- assert.deepEqual(args.kwargs.context, {
- active_field: 2,
- someKey: 'some value',
- }, "sent context should be correct");
- }
- return this._super.apply(this, arguments);
- },
- session: {
- user_context: {someKey: 'some value'},
- },
- viewOptions: {
- mode: 'edit',
- context: {active_field: 2},
- },
- res_id: 1,
- });
-
- form.$('.o_data_cell:first').click();
- form.$('.o_field_widget[name=display_name]').val('abc').trigger('input');
- form.$buttons.find('.o_form_button_save').click();
-
- form.destroy();
- });
-
- QUnit.test('resetting invisible one2manys', function (assert) {
- assert.expect(3);
-
- this.data.partner.records[0].turtles = [];
- this.data.partner.onchanges.foo = function (obj) {
- obj.turtles = [[5], [4, 1]];
- };
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form>' +
- '<field name="foo"/>' +
- '<field name="turtles" invisible="1"/>' +
- '</form>',
- viewOptions: {
- mode: 'edit',
- },
- res_id: 1,
- mockRPC: function (route, args) {
- assert.step(args.method);
- return this._super.apply(this, arguments);
- },
- });
-
- form.$('input[name="foo"]').val('abcd').trigger('input');
- assert.verifySteps(['read', 'onchange']);
-
- form.destroy();
- });
-
- QUnit.test('one2many: onchange that returns unknow field in list, but not in form', function (assert) {
- assert.expect(5);
-
- this.data.partner.onchanges = {
- name: function () {},
- };
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="name"/>' +
- '<field name="p">' +
- '<tree>' +
- '<field name="display_name"/>' +
- '</tree>' +
- '<form string="Partners">' +
- '<field name="display_name"/>' +
- '<field name="timmy" widget="many2many_tags"/>' +
- '</form>' +
- '</field>' +
- '</form>',
- mockRPC: function (route, args) {
- if (args.method === 'onchange') {
- return $.when({
- value: {
- p: [[5], [0, 0, {display_name: 'new', timmy: [[5], [4, 12]]}]],
- },
- });
- }
- return this._super.apply(this, arguments);
- },
- });
-
- assert.strictEqual(form.$('.o_data_row').length, 1,
- "the one2many should contain one row");
- assert.strictEqual(form.$('.o_field_widget[name="timmy"]').length, 0,
- "timmy should not be displayed in the list view");
-
- form.$('.o_data_row td:first').click(); // open the record
-
- assert.strictEqual($('.modal .o_field_many2manytags[name="timmy"]').length, 1,
- "timmy should be displayed in the form view");
- assert.strictEqual($('.modal .o_field_many2manytags[name="timmy"] .badge').length, 1,
- "m2mtags should contain one tag");
- assert.strictEqual($('.modal .o_field_many2manytags[name="timmy"] .o_badge_text').text(),
- 'gold', "tag name should have been correctly loaded");
-
- form.destroy();
- });
-
- QUnit.test('onchange and required fields with override in arch', function (assert) {
- assert.expect(4);
-
- this.data.partner.onchanges = {
- turtles: function (obj) {}
- };
- this.data.turtle.fields.turtle_foo.required = true;
- this.data.partner.records[0].turtles = [];
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="turtles">' +
- '<tree editable="bottom">' +
- '<field name="turtle_int"/>' +
- '<field name="turtle_foo" required="0"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- mockRPC: function (route, args) {
- assert.step(args.method);
- return this._super.apply(this, arguments);
- },
- });
- form.$buttons.find('.o_form_button_edit').click();
-
- // triggers an onchange on partner, because the new record is valid
- form.$('.o_field_x2many_list_row_add a').click();
-
- assert.verifySteps(['read', 'default_get', 'onchange']);
- form.destroy();
- });
-
- QUnit.test('onchange on a one2many containing a one2many', function (assert) {
- // the purpose of this test is to ensure that the onchange specs are
- // correctly and recursively computed
- assert.expect(1);
-
- this.data.partner.onchanges = {
- p: function () {}
- };
- var checkOnchange = false;
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="p">' +
- '<tree><field name="display_name"/></tree>' +
- '<form>' +
- '<field name="display_name"/>' +
- '<field name="p">' +
- '<tree editable="bottom"><field name="display_name"/></tree>' +
- '</field>' +
- '</form>' +
- '</field>' +
- '</form>',
- mockRPC: function (route, args) {
- if (args.method === 'onchange' && checkOnchange) {
- assert.strictEqual(args.args[3]['p.p.display_name'], '',
- "onchange specs should be computed recursively");
- }
- return this._super.apply(this, arguments);
- },
- });
-
- form.$('.o_field_x2many_list_row_add a').click();
- $('.modal .o_field_x2many_list_row_add a').click();
- $('.modal .o_data_cell input').val('new record').trigger('input');
- checkOnchange = true;
- $('.modal .modal-footer .btn-primary').click(); // save (should trigger the onchange)
-
- form.destroy();
- });
-
- QUnit.test('editing tabbed one2many (editable=bottom)', function (assert) {
- assert.expect(12);
-
- this.data.partner.records[0].turtles = [];
- for (var i = 0; i < 42; i++) {
- var id = 100 + i;
- this.data.turtle.records.push({id: id, turtle_foo: 'turtle' + (id-99)});
- this.data.partner.records[0].turtles.push(id);
- }
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<sheet>' +
- '<field name="turtles">' +
- '<tree editable="bottom">' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '</field>' +
- '</sheet>' +
- '</form>',
- res_id: 1,
- mockRPC: function (route, args) {
- assert.step(args.method);
- if (args.method === 'write') {
- assert.strictEqual(args.args[1].turtles[40][0], 0, 'should send a create command');
- assert.deepEqual(args.args[1].turtles[40][2], {turtle_foo: 'rainbow dash'});
- }
- return this._super.apply(this, arguments);
- },
- });
-
-
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_field_x2many_list_row_add a').click();
-
- assert.strictEqual(form.$('tr.o_data_row').length, 41,
- "should have 41 data rows on the current page");
- assert.ok(form.$('tr.o_data_row').last().hasClass('o_selected_row'),
- "last row should be selected");
-
- form.$('.o_data_row input[name="turtle_foo"]').val('rainbow dash').trigger('input');
- form.$buttons.find('.o_form_button_save').click();
-
- assert.strictEqual(form.$('tr.o_data_row').length, 40,
- "should have 40 data rows on the current page");
-
- assert.verifySteps(['read', 'read', 'default_get', 'write', 'read', 'read']);
- form.destroy();
- });
-
- QUnit.test('editing tabbed one2many (editable=bottom), again...', function (assert) {
- assert.expect(1);
-
- this.data.partner.records[0].turtles = [];
- for (var i = 0; i < 9; i++) {
- var id = 100 + i;
- this.data.turtle.records.push({id: id, turtle_foo: 'turtle' + (id-99)});
- this.data.partner.records[0].turtles.push(id);
- }
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="turtles">' +
- '<tree editable="bottom" limit="3">' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- });
-
-
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_field_x2many_list_row_add a').click();
- form.$('.o_data_row input[name="turtle_foo"]').val('rainbow dash').trigger('input');
- form.$('.o_x2m_control_panel .o_pager_next').click();
- form.$('.o_x2m_control_panel .o_pager_next').click();
-
- assert.strictEqual(form.$('tr.o_data_row').length, 3,
- "should have 3 data rows on the current page");
- form.destroy();
- });
-
- QUnit.test('editing tabbed one2many (editable=top)', function (assert) {
- assert.expect(15);
-
- this.data.partner.records[0].turtles = [];
- this.data.turtle.fields.turtle_foo.default = "default foo";
- for (var i = 0; i < 42; i++) {
- var id = 100 + i;
- this.data.turtle.records.push({id: id, turtle_foo: 'turtle' + (id-99)});
- this.data.partner.records[0].turtles.push(id);
- }
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<sheet>' +
- '<field name="turtles">' +
- '<tree editable="top">' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '</field>' +
- '</sheet>' +
- '</form>',
- res_id: 1,
- mockRPC: function (route, args) {
- assert.step(args.method);
- if (args.method === 'write') {
- assert.strictEqual(args.args[1].turtles[40][0], 0);
- assert.deepEqual(args.args[1].turtles[40][2], {turtle_foo: 'rainbow dash'});
- }
- return this._super.apply(this, arguments);
- },
- });
-
-
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_pager_next').click();
-
- assert.strictEqual(form.$('tr.o_data_row').length, 2,
- "should have 2 data rows on the current page");
-
- form.$('.o_field_x2many_list_row_add a').click();
-
- assert.strictEqual(form.$('tr.o_data_row').length, 3,
- "should have 3 data rows on the current page (2 records and the created line)");
-
- assert.ok(form.$('tr.o_data_row').first().hasClass('o_selected_row'),
- "first row should be selected");
-
- assert.strictEqual(form.$('tr.o_data_row input').val(), 'default foo',
- "selected input should have correct string");
-
- form.$('.o_data_row input[name="turtle_foo"]').val('rainbow dash').trigger('input');
- form.$buttons.find('.o_form_button_save').click();
-
- assert.strictEqual(form.$('tr.o_data_row').length, 40,
- "should have 40 data rows on the current page");
-
- assert.verifySteps(['read', 'read', 'read', 'default_get', 'write', 'read', 'read']);
- form.destroy();
- });
-
- QUnit.test('one2many field: change value before pending onchange returns', function (assert) {
- var done = assert.async();
- assert.expect(2);
-
- var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
- relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
-
- this.data.partner.onchanges = {
- int_field: function () {}
- };
- var def;
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="p">' +
- '<tree editable="bottom">' +
- '<field name="int_field"/>' +
- '<field name="trululu"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- mockRPC: function (route, args) {
- var result = this._super.apply(this, arguments);
- if (args.method === 'onchange') {
- // delay the onchange RPC
- return $.when(def).then(_.constant(result));
- }
- return result;
- },
- });
-
- form.$('.o_field_x2many_list_row_add a').click();
- def = $.Deferred();
- form.$('.o_field_widget[name=int_field]')
- .val('44')
- .trigger('input');
-
- var $dropdown = form.$('.o_field_many2one input').autocomplete('widget');
- // set trululu before onchange
- form.$('.o_field_many2one input').val('first').trigger('keydown').trigger('keyup');
- // complete the onchange
- def.resolve();
- assert.strictEqual(form.$('.o_field_many2one input').val(), 'first',
- 'should have kept the new value');
- concurrency.delay(0).then(function () {
- // check name_search result
- assert.strictEqual($dropdown.find('li:not(.o_m2o_dropdown_option)').length, 1,
- 'autocomplete should contains 1 suggestion');
-
- relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
- form.destroy();
- done();
- });
- });
-
- QUnit.test('focus is correctly reset after an onchange in an x2many', function (assert) {
- assert.expect(2);
-
- this.data.partner.onchanges = {
- int_field: function () {}
- };
- var def;
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="p">' +
- '<tree editable="bottom">' +
- '<field name="int_field"/>' +
- '<button string="hello"/>' +
- '<field name="qux"/>' +
- '<field name="trululu"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- mockRPC: function (route, args) {
- var result = this._super.apply(this, arguments);
- if (args.method === 'onchange') {
- // delay the onchange RPC
- return $.when(def).then(_.constant(result));
- }
- return result;
- },
- });
-
- form.$('.o_field_x2many_list_row_add a').click();
- def = $.Deferred();
- form.$('.o_field_widget[name=int_field]')
- .val('44')
- .trigger('input')
- .trigger({type: 'keydown', which: $.ui.keyCode.TAB});
- def.resolve();
-
- assert.strictEqual(document.activeElement, form.$('.o_field_widget[name=qux]')[0],
- "qux field should have the focus");
-
- form.$('.o_field_many2one input').click();
- form.$('.o_field_many2one input').autocomplete('widget').find('a').first().click();
- assert.strictEqual(form.$('.o_field_many2one input').val(), 'first record',
- "the one2many field should have the expected value");
-
- form.destroy();
- });
-
- QUnit.test('checkbox in an x2many that triggers an onchange', function (assert) {
- assert.expect(1);
-
- this.data.partner.onchanges = {
- bar: function () {}
- };
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="p">' +
- '<tree editable="bottom">' +
- '<field name="bar"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- });
-
- form.$('.o_field_x2many_list_row_add a').click();
-
- form.$('.o_field_widget[name=bar] input').click();
- assert.notOk(form.$('.o_field_widget[name=bar] input').prop('checked'),
- "the checkbox should be unticked");
-
- form.destroy();
- });
-
- QUnit.test('one2many with default value: edit line to make it invalid', function (assert) {
- assert.expect(3);
-
- this.data.partner.fields.p.default = [
- [0, false, {foo: "coucou", int_field: 5}],
- ];
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="p">' +
- '<tree editable="bottom">' +
- '<field name="foo"/>' +
- '<field name="int_field"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- });
-
- // edit the line and enter an invalid value for int_field
- form.$('.o_data_row .o_data_cell:nth(1)').click();
- form.$('.o_field_widget[name=int_field]').val('e').trigger('input');
- form.$el.click(); // try to validate the line
-
- assert.strictEqual(form.$('.o_data_row.o_selected_row').length, 1,
- "line should not have been removed and should still be in edition");
- assert.strictEqual($('.modal').length, 1,
- "a confirmation dialog should be opened");
- assert.ok(form.$('.o_field_widget[name=int_field]').hasClass('o_field_invalid'),
- "should indicate that int_field is invalid");
-
- form.destroy();
- });
-
- QUnit.test('default value for nested one2manys (coming from onchange)', function (assert) {
- assert.expect(3);
-
- this.data.partner.onchanges.p = function (obj) {
- obj.p = [
- [5],
- [0, 0, {turtles: [[5], [4, 1]]}], // link record 1 by default
- ];
- };
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form>' +
- '<sheet>' +
- '<field name="p">' +
- '<tree><field name="turtles"/></tree>' +
- '</field>' +
- '</sheet>' +
- '</form>',
- mockRPC: function (route, args) {
- if (args.method === 'create') {
- assert.strictEqual(args.args[0].p[0][0], 0,
- "should send a command 0 (CREATE) for p");
- assert.deepEqual(args.args[0].p[0][2], {turtles: [[4, 1, false]]},
- "should send the correct values");
- }
- return this._super.apply(this, arguments);
- },
- });
-
- assert.strictEqual(form.$('.o_data_cell').text(), '1 record',
- "should correctly display the value of the inner o2m");
-
- form.$buttons.find('.o_form_button_save').click();
-
- form.destroy();
- });
-
- QUnit.test('display correct value after validation error', function (assert) {
- assert.expect(4);
-
- this.data.partner.onchanges.turtles = function () { };
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form>' +
- '<sheet>' +
- '<field name="turtles">' +
- '<tree editable="bottom">' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '</field>' +
- '</sheet>' +
- '</form>',
- mockRPC: function (route, args) {
- if (args.method === 'onchange') {
- if (args.args[1].turtles[0][2].turtle_foo === 'pinky') {
- // we simulate a validation error. In the 'real' web client,
- // the server error will be used by the session to display
- // an error dialog. From the point of view of the basic
- // model, the deferred is just rejected.
- return $.Deferred().reject();
- }
- }
- if (args.method === 'write') {
- assert.deepEqual(args.args[1].turtles[0], [1,2,{turtle_foo: 'foo'}],
- 'should send the "good" value');
- }
- return this._super.apply(this, arguments);
- },
- viewOptions: {mode: 'edit'},
- res_id: 1,
- });
-
- assert.strictEqual(form.$('.o_data_row .o_data_cell:nth(0)').text(), 'blip',
- "initial text should be correct");
-
- // click and edit value to 'foo', which will trigger onchange
- form.$('.o_data_row .o_data_cell:nth(0)').click();
- form.$('.o_field_widget[name=turtle_foo]').val('foo').trigger('input');
- form.$el.click();
- assert.strictEqual(form.$('.o_data_row .o_data_cell:nth(0)').text(), 'foo',
- "field should have been changed to foo");
-
- // click and edit value to 'pinky', which trigger a failed onchange
- form.$('.o_data_row .o_data_cell:nth(0)').click();
- form.$('.o_field_widget[name=turtle_foo]').val('pinky').trigger('input');
- form.$el.click();
-
- assert.strictEqual(form.$('.o_data_row .o_data_cell:nth(0)').text(), 'foo',
- "turtle_foo text should now be set back to foo");
-
- // we make sure here that when we save, the values are the current
- // values displayed in the field.
- form.$buttons.find('.o_form_button_save').click();
-
- form.destroy();
- });
-
- QUnit.test('propagate context to sub views', function (assert) {
- assert.expect(5);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form>' +
- '<sheet>' +
- '<field name="turtles">' +
- '<tree editable="bottom">' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '</field>' +
- '</sheet>' +
- '</form>',
- mockRPC: function (route, args) {
- assert.strictEqual(args.kwargs.context.flutter, 'shy',
- 'view context key should be used for every rpcs');
- return this._super.apply(this, arguments);
- },
- viewOptions: {context: {flutter: 'shy'}},
- });
- form.$('.o_field_x2many_list_row_add a').click();
- form.$('input[name="turtle_foo"]').val('pinky pie').trigger('input');
- form.$buttons.find('.o_form_button_save').click();
-
- form.destroy();
- });
-
- QUnit.test('onchange on nested one2manys', function (assert) {
- assert.expect(6);
-
- this.data.partner.onchanges.display_name = function (obj) {
- if (obj.display_name) {
- obj.p = [
- [5],
- [0, 0, {
- display_name: 'test',
- turtles: [[5], [0, 0, {display_name: 'test nested'}]],
- }],
- ];
- }
- };
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form>' +
- '<sheet>' +
- '<field name="display_name"/>' +
- '<field name="p">' +
- '<tree>' +
- '<field name="display_name"/>' +
- '</tree>' +
- '<form>' +
- '<field name="turtles">' +
- '<tree><field name="display_name"/></tree>' +
- '</field>' +
- '</form>' +
- '</field>' +
- '</sheet>' +
- '</form>',
- mockRPC: function (route, args) {
- if (args.method === 'create') {
- assert.strictEqual(args.args[0].p[0][0], 0,
- "should send a command 0 (CREATE) for p");
- assert.strictEqual(args.args[0].p[0][2].display_name, 'test',
- "should send the correct values");
- assert.strictEqual(args.args[0].p[0][2].turtles[0][0], 0,
- "should send a command 0 (CREATE) for turtles");
- assert.deepEqual(args.args[0].p[0][2].turtles[0][2], {display_name: 'test nested'},
- "should send the correct values");
- }
- return this._super.apply(this, arguments);
- },
- });
-
- form.$('.o_field_widget[name=display_name]').val('trigger onchange').trigger('input');
-
- assert.strictEqual(form.$('.o_data_cell').text(), 'test',
- "should have added the new row to the one2many");
-
- // open the new subrecord to check the value of the nested o2m, and to
- // ensure that it will be saved
- form.$('.o_data_cell:first').click();
- assert.strictEqual($('.modal .o_data_cell').text(), 'test nested',
- "should have added the new row to the nested one2many");
- $('.modal .modal-footer .btn-primary').click();
-
- form.$buttons.find('.o_form_button_save').click();
-
- form.destroy();
- });
-
- QUnit.test('one2many with multiple pages and sequence field', function (assert) {
- assert.expect(1);
-
- this.data.partner.records[0].turtles = [3, 2, 1];
- this.data.partner.onchanges.turtles = function () {};
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="turtles">' +
- '<tree limit="2">' +
- '<field name="turtle_int" widget="handle"/>' +
- '<field name="turtle_foo"/>' +
- '<field name="partner_ids" invisible="1"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- mockRPC: function (route, args) {
- if (args.method === 'onchange') {
- return $.when({value: { turtles: [
- [5],
- [1, 1, {turtle_foo: "from onchange", partner_ids: [[5]]}],
- ]}});
- }
- return this._super(route, args);
- },
- viewOptions: {
- mode: 'edit',
- },
- });
- form.$('.o_list_record_remove:first button').click();
- assert.strictEqual(form.$('.o_data_row').text(), 'from onchange',
- 'onchange has been properly applied');
- form.destroy();
- });
-
- QUnit.test('one2many with multiple pages and sequence field, part2', function (assert) {
- assert.expect(1);
-
- this.data.partner.records[0].turtles = [3, 2, 1];
- this.data.partner.onchanges.turtles = function () {};
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="turtles">' +
- '<tree limit="2">' +
- '<field name="turtle_int" widget="handle"/>' +
- '<field name="turtle_foo"/>' +
- '<field name="partner_ids" invisible="1"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- mockRPC: function (route, args) {
- if (args.method === 'onchange') {
- return $.when({value: { turtles: [
- [5],
- [1, 1, {turtle_foo: "from onchange id2", partner_ids: [[5]]}],
- [1, 3, {turtle_foo: "from onchange id3", partner_ids: [[5]]}],
- ]}});
- }
- return this._super(route, args);
- },
- viewOptions: {
- mode: 'edit',
- },
- });
- form.$('.o_list_record_remove:first button').click();
- assert.strictEqual(form.$('.o_data_row').text(), 'from onchange id2from onchange id3',
- 'onchange has been properly applied');
- form.destroy();
- });
-
- QUnit.test('one2many with sequence field, override default_get, bottom when inline', function (assert) {
- assert.expect(2);
-
- this.data.partner.records[0].turtles = [3, 2, 1];
-
- this.data.turtle.fields.turtle_int.default = 10;
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="turtles">' +
- '<tree editable="bottom">' +
- '<field name="turtle_int" widget="handle"/>' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- viewOptions: {
- mode: 'edit',
- },
- });
-
- // starting condition
- assert.strictEqual($('.o_data_cell').text(), "blipyopkawa");
-
- // click add a new line
- // save the record
- // check line is at the correct place
-
- var inputText = 'ninja';
- form.$('.o_field_x2many_list_row_add a').click();
- form.$('.o_input[name="turtle_foo"]').val(inputText).trigger('input');
- form.$buttons.find('.o_form_button_save').click();
-
- assert.strictEqual($('.o_data_cell').text(), "blipyopkawa" + inputText);
- form.destroy();
- });
-
- QUnit.test('one2many with sequence field, override default_get, top when inline', function (assert) {
- assert.expect(2);
-
- this.data.partner.records[0].turtles = [3, 2, 1];
-
- this.data.turtle.fields.turtle_int.default = 10;
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="turtles">' +
- '<tree editable="top">' +
- '<field name="turtle_int" widget="handle"/>' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- viewOptions: {
- mode: 'edit',
- },
- });
-
- // starting condition
- assert.strictEqual($('.o_data_cell').text(), "blipyopkawa");
-
- // click add a new line
- // save the record
- // check line is at the correct place
-
- var inputText = 'ninja';
- form.$('.o_field_x2many_list_row_add a').click();
- form.$('.o_input[name="turtle_foo"]').val(inputText).trigger('input');
- form.$buttons.find('.o_form_button_save').click();
-
- assert.strictEqual($('.o_data_cell').text(), inputText + "blipyopkawa");
- form.destroy();
- });
-
- QUnit.test('one2many with sequence field, override default_get, bottom when popup', function (assert) {
- assert.expect(3);
-
- this.data.partner.records[0].turtles = [3, 2, 1];
-
- this.data.turtle.fields.turtle_int.default = 10;
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="turtles">' +
- '<tree>' +
- '<field name="turtle_int" widget="handle"/>' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '<form>' +
- // NOTE: at some point we want to fix this in the framework so that an invisible field is not required.
- '<field name="turtle_int" invisible="1"/>' +
- '<field name="turtle_foo"/>' +
- '</form>' +
- '</field>' +
- '</form>',
- res_id: 1,
- viewOptions: {
- mode: 'edit',
- },
- });
-
- // starting condition
- assert.strictEqual($('.o_data_cell').text(), "blipyopkawa");
-
- // click add a new line
- // save the record
- // check line is at the correct place
-
- var inputText = 'ninja';
- $('.o_field_x2many_list_row_add a').click();
- $('.o_input[name="turtle_foo"]').val(inputText).trigger('input');
- $('.modal .modal-footer .btn-primary:first').click();
-
- assert.strictEqual($('.o_data_cell').text(), "blipyopkawa" + inputText);
-
- $('.o_form_button_save').click();
- assert.strictEqual($('.o_data_cell').text(), "blipyopkawa" + inputText);
- form.destroy();
- });
-
- QUnit.test('one2many with sequence field, override default_get, not last page', function (assert) {
- assert.expect(1);
-
- this.data.partner.records[0].turtles = [3, 2, 1];
-
- this.data.turtle.fields.turtle_int.default = 10;
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="turtles">' +
- '<tree limit="2">' +
- '<field name="turtle_int" widget="handle"/>' +
- '</tree>' +
- '<form>' +
- '<field name="turtle_int"/>' +
- '</form>' +
- '</field>' +
- '</form>',
- res_id: 1,
- viewOptions: {
- mode: 'edit',
- },
- });
-
- // click add a new line
- // check turtle_int for new is the current max of the page
- $('.o_field_x2many_list_row_add a').click();
- assert.strictEqual($('.modal .o_input[name="turtle_int"]').val(), '10');
- form.destroy();
- });
-
- QUnit.test('one2many with sequence field, override default_get, last page', function (assert) {
- assert.expect(1);
-
- this.data.partner.records[0].turtles = [3, 2, 1];
-
- this.data.turtle.fields.turtle_int.default = 10;
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="turtles">' +
- '<tree limit="4">' +
- '<field name="turtle_int" widget="handle"/>' +
- '</tree>' +
- '<form>' +
- '<field name="turtle_int"/>' +
- '</form>' +
- '</field>' +
- '</form>',
- res_id: 1,
- viewOptions: {
- mode: 'edit',
- },
- });
-
- // click add a new line
- // check turtle_int for new is the current max of the page +1
- $('.o_field_x2many_list_row_add a').click();
- assert.strictEqual($('.modal .o_input[name="turtle_int"]').val(), '22');
- form.destroy();
- });
-
- QUnit.test('one2many with sequence field, fetch name_get from empty list, field text', function (assert) {
- // There was a bug where a RPC would fail because no route was set.
- // The scenario is:
- // - create a new parent model, which has a one2many
- // - add at least 2 one2many lines which have:
- // - a handle field
- // - a many2one, which is not required, and we will leave it empty
- // - reorder the lines with the handle
- // -> This will call a resequence, which calls a name_get.
- // -> With the bug that would fail, if it's ok the test will pass.
-
- // This test will also make sure lists with
- // FieldText (turtle_description) can be reordered with a handle.
- // More specifically this will trigger a reset on a FieldText
- // while the field is not in editable mode.
- assert.expect(4);
-
- this.data.turtle.fields.turtle_int.default = 10;
- this.data.turtle.fields.product_id.default = 37;
- this.data.turtle.fields.not_required_product_id = {
- string: "Product",
- type: "many2one",
- relation: 'product'
- };
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="turtles">' +
- '<tree editable="bottom">' +
- '<field name="turtle_int" widget="handle"/>' +
- '<field name="turtle_foo"/>' +
- '<field name="not_required_product_id"/>' +
- '<field name="turtle_description" widget="text"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- viewOptions: {
- mode: 'edit',
- },
- });
-
- // starting condition
- assert.strictEqual($('.o_data_cell:nth-child(2)').text(), "");
-
- var inputText1 = 'relax';
- var inputText2 = 'max';
- form.$('.o_field_x2many_list_row_add a').click();
- form.$('.o_input[name="turtle_foo"]').val(inputText1).trigger('input');
- form.$('.o_field_x2many_list_row_add a').click();
- form.$('.o_input[name="turtle_foo"]').val(inputText2).trigger('input');
- form.$('.o_field_x2many_list_row_add a').click();
-
- assert.strictEqual($('.o_data_cell:nth-child(2)').text(), inputText1 + inputText2);
-
- var $handles = form.$('.ui-sortable-handle');
-
- assert.equal($handles.length, 3, 'There should be 3 sequence handlers');
-
- testUtils.dragAndDrop($handles.eq(1),
- form.$('tbody tr').first(),
- {position: 'top'}
- );
-
- assert.strictEqual($('.o_data_cell:nth-child(2)').text(), inputText2 + inputText1);
-
- form.destroy();
- });
-
- QUnit.test('one2many with several pages, onchange and default order', function (assert) {
- // This test reproduces a specific scenario where a one2many is displayed
- // over several pages, and has a default order such that a record that
- // would normally be on page 1 is actually on another page. Moreover,
- // there is an onchange on that one2many which converts all commands 4
- // (LINK_TO) into commands 1 (UPDATE), which is standard in the ORM.
- // This test ensures that the record displayed on page 2 is never fully
- // read.
- assert.expect(8);
-
- var data = this.data;
- data.partner.records[0].turtles = [1, 2, 3];
- data.turtle.records[0].partner_ids = [1];
- data.partner.onchanges = {
- turtles: function (obj) {
- var res = _.map(obj.turtles, function (command) {
- if (command[0] === 1) { // already an UPDATE command: do nothing
- return command;
- }
- // convert LINK_TO commands to UPDATE commands
- var id = command[1];
- var record = _.findWhere(data.turtle.records, {id: id});
- return [1, id, _.pick(record, ['turtle_int', 'turtle_foo', 'partner_ids'])];
- });
- obj.turtles = [[5]].concat(res);
- },
- };
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: data,
- arch: '<form string="Partners">' +
- '<field name="turtles">' +
- '<tree editable="top" limit="2" default_order="turtle_foo">' +
- '<field name="turtle_int"/>' +
- '<field name="turtle_foo" class="foo"/>' +
- '<field name="partner_ids" widget="many2many_tags"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- mockRPC: function (route, args) {
- var ids = args.method === 'read' ? ' [' + args.args[0] + ']' : '';
- assert.step(args.method + ids);
- return this._super.apply(this, arguments);
- },
- res_id: 1,
- viewOptions: {
- mode: 'edit',
- },
- });
-
- assert.strictEqual(form.$('.o_data_cell.foo').text(), 'blipkawa',
- "should display two records out of three, in the correct order");
-
- // edit turtle_int field of first row
- form.$('.o_data_cell:first').click();
- form.$('.o_field_widget[name=turtle_int]').val(3).trigger('input');
- form.$el.click(); // leave edition
-
- assert.strictEqual(form.$('.o_data_cell.foo').text(), 'blipkawa',
- "should still display the same two records");
-
- assert.verifySteps([
- 'read [1]', // main record
- 'read [1,2,3]', // one2many (turtle_foo, all records)
- 'read [2,3]', // one2many (all fields in view, records of first page)
- 'read [2,4]', // many2many inside one2many (partner_ids), first page only
- 'onchange',
- ]);
-
- form.destroy();
- });
-
- QUnit.test('new record, with one2many with more default values than limit', function (assert) {
- assert.expect(2);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="turtles">' +
- '<tree limit="2">' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- context: { default_turtles: [1,2,3]},
- viewOptions: {
- mode: 'edit',
- },
- });
- assert.strictEqual(form.$('.o_data_row').text(), 'yopblip',
- 'data has been properly loaded');
- form.$buttons.find('.o_form_button_save').click();
-
- assert.strictEqual(form.$('.o_data_row').text(), 'yopblip',
- 'data has been properly saved');
- form.destroy();
- });
-
- QUnit.test('add a new line after limit is reached should behave nicely', function (assert) {
- assert.expect(2);
-
- this.data.partner.records[0].turtles = [1,2,3];
-
- this.data.partner.onchanges = {
- turtles: function (obj) {
- obj.turtles = [
- [5],
- [1, 1, {turtle_foo: "yop"}],
- [1, 2, {turtle_foo: "blip"}],
- [1, 3, {turtle_foo: "kawa"}],
- [0, obj.turtles[3][2], {turtle_foo: "abc"}],
- ];
- },
- };
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="turtles">' +
- '<tree limit="3" editable="bottom">' +
- '<field name="turtle_foo" required="1"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- viewOptions: {
- mode: 'edit',
- },
- });
-
- form.$('.o_field_x2many_list_row_add a').click();
- assert.strictEqual(form.$('.o_data_row').length, 4, 'should have 4 data rows');
- form.$('.o_input[name="turtle_foo"]').val('a').trigger('input');
- assert.strictEqual(form.$('.o_data_row').length, 4,
- 'should still have 4 data rows (the limit is increased to 4)');
-
- form.destroy();
- });
-
- QUnit.test('onchange in a one2many with non inline view on an existing record', function (assert) {
- assert.expect(6);
-
- this.data.partner.fields.sequence = {string: 'Sequence', type: 'integer'};
- this.data.partner.records[0].sequence = 1;
- this.data.partner.records[1].sequence = 2;
- this.data.partner.onchanges = {sequence: function () {}};
-
- this.data.partner_type.fields.partner_ids = {string: "Partner", type: "one2many", relation: 'partner'};
- this.data.partner_type.records[0].partner_ids = [1,2];
-
- var form = testUtils.createView({
- View: FormView,
- model: 'partner_type',
- data: this.data,
- arch: '<form><field name="partner_ids"/></form>',
- archs: {
- 'partner,false,list': '<tree string="Vendors">' +
- '<field name="sequence" widget="handle"/>' +
- '<field name="display_name"/>' +
- '</tree>',
- },
- res_id: 12,
- mockRPC: function (route, args) {
- assert.step(args.method);
- return this._super.apply(this, arguments);
- },
- viewOptions: {mode: 'edit'},
- });
-
- // swap 2 lines in the one2many
- testUtils.dragAndDrop(form.$('.ui-sortable-handle:eq(1)'), form.$('tbody tr').first(),
- {position: 'top'});
- assert.verifySteps(['load_views', 'read', 'read', 'onchange', 'onchange']);
- form.destroy();
- });
-
- QUnit.test('onchange in a one2many with non inline view on a new record', function (assert) {
- assert.expect(8);
-
- this.data.turtle.onchanges = {
- display_name: function (obj) {
- if (obj.display_name) {
- obj.turtle_int = 44;
- }
- },
- };
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form><field name="turtles"/></form>',
- archs: {
- 'turtle,false,list': '<tree editable="bottom">' +
- '<field name="display_name"/>' +
- '<field name="turtle_int"/>' +
- '</tree>',
- },
- mockRPC: function (route, args) {
- assert.step(args.method || route);
- return this._super.apply(this, arguments);
- },
- });
-
- // add a row and trigger the onchange
- form.$('.o_field_x2many_list_row_add a').click();
- form.$('.o_data_row .o_field_widget[name=display_name]').val('a name').trigger('input');
-
- assert.strictEqual(form.$('.o_data_row .o_field_widget[name=turtle_int]').val(), "44",
- "should have triggered the onchange");
-
- assert.verifySteps([
- 'load_views', // load sub list
- 'default_get', // main record
- 'onchange', // main record
- 'default_get', // sub record
- 'onchange', // sub record
- 'onchange', // edition of display_name of sub record
- ]);
-
- form.destroy();
- });
-
- QUnit.test('add a line, edit it and "Save & New"', function (assert) {
- assert.expect(5);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="p">' +
- '<tree><field name="display_name"/></tree>' +
- '<form><field name="display_name"/></form>' +
- '</field>' +
- '</form>',
- });
-
- assert.strictEqual(form.$('.o_data_row').length, 0,
- "there should be no record in the relation");
-
- // add a new record
- form.$('.o_field_x2many_list_row_add a').click();
- $('.modal .o_field_widget').val('new record').trigger('input');
- $('.modal .modal-footer .btn-primary:first').click(); // Save & Close
-
- assert.strictEqual(form.$('.o_data_row .o_data_cell').text(), 'new record',
- "should display the new record");
-
- // reopen freshly added record and edit it
- form.$('.o_data_row .o_data_cell').click();
- $('.modal .o_field_widget').val('new record edited').trigger('input');
-
- // save it, and choose to directly create another record
- $('.modal .modal-footer .btn-primary:nth(1)').click(); // Save & New
-
- assert.strictEqual($('.modal').length, 1,
- "the model should still be open");
- assert.strictEqual($('.modal .o_field_widget').text(), '',
- "should have cleared the input");
-
- $('.modal .o_field_widget').val('another new record').trigger('input');
- $('.modal .modal-footer .btn-primary:first').click(); // Save & Close
-
- assert.strictEqual(form.$('.o_data_row .o_data_cell').text(),
- 'new record editedanother new record', "should display the two records");
-
- form.destroy();
- });
-
- QUnit.test('o2m add a line custom control create editable', function (assert) {
- assert.expect(5);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:
- '<form string="Partners">' +
- '<field name="p">' +
- '<tree editable="bottom">' +
- '<control>' +
- '<create string="Add food" context="" />' +
- '<create string="Add pizza" context="{\'default_display_name\': \'pizza\'}"/>' +
- '</control>' +
-
- '<control>' +
- '<create string="Add pasta" context="{\'default_display_name\': \'pasta\'}"/>' +
- '</control>' +
-
- '<field name="display_name"/>' +
- '</tree>' +
- '<form>' +
- '<field name="display_name"/>' +
- '</form>' +
- '</field>' +
- '</form>',
- });
-
- // new controls correctly added
- var $td = form.$('.o_field_x2many_list_row_add');
- assert.strictEqual($td.length, 1);
- assert.strictEqual($td.closest('tr').find('td').length, 1);
- assert.strictEqual($td.text(), "Add foodAdd pizzaAdd pasta");
-
- // click add food
- // check it's empty
- form.$('.o_field_x2many_list_row_add a:eq(0)').click();
- assert.strictEqual($('.o_data_cell').text(), "");
-
- // click add pizza
- // save the modal
- // check it's pizza
- form.$('.o_field_x2many_list_row_add a:eq(1)').click();
- // click add pasta
- form.$('.o_field_x2many_list_row_add a:eq(2)').click();
- form.$buttons.find('.o_form_button_save').click();
- assert.strictEqual($('.o_data_cell').text(), "pizzapasta");
-
- form.destroy();
- });
-
- QUnit.test('o2m add a line custom control create non-editable', function (assert) {
- assert.expect(6);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:
- '<form string="Partners">' +
- '<field name="p">' +
- '<tree>' +
- '<control>' +
- '<create string="Add food" context="" />' +
- '<create string="Add pizza" context="{\'default_display_name\': \'pizza\'}" />' +
- '</control>' +
-
- '<control>' +
- '<create string="Add pasta" context="{\'default_display_name\': \'pasta\'}" />' +
- '</control>' +
-
- '<field name="display_name"/>' +
- '</tree>' +
- '<form>' +
- '<field name="display_name"/>' +
- '</form>' +
- '</field>' +
- '</form>',
- });
-
- // new controls correctly added
- var $td = form.$('.o_field_x2many_list_row_add');
- assert.strictEqual($td.length, 1);
- assert.strictEqual($td.closest('tr').find('td').length, 1);
- assert.strictEqual($td.text(), "Add foodAdd pizzaAdd pasta");
-
- // click add food
- // check it's empty
- form.$('.o_field_x2many_list_row_add a:eq(0)').click();
- $('.modal .modal-footer .btn-primary:first').click();
- assert.strictEqual($('.o_data_cell').text(), "");
-
- // click add pizza
- // save the modal
- // check it's pizza
- form.$('.o_field_x2many_list_row_add a:eq(1)').click();
- $('.modal .modal-footer .btn-primary:first').click();
- assert.strictEqual($('.o_data_cell').text(), "pizza");
-
- // click add pasta
- // save the whole record
- // check it's pizzapasta
- form.$('.o_field_x2many_list_row_add a:eq(2)').click();
- $('.modal .modal-footer .btn-primary:first').click();
- assert.strictEqual($('.o_data_cell').text(), "pizzapasta");
-
- form.destroy();
- });
-
- QUnit.test('o2m add a line custom control create align with handle', function (assert) {
- assert.expect(3);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:
- '<form string="Partners">' +
- '<field name="p">' +
- '<tree>' +
- '<field name="int_field" widget="handle"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- });
-
- // controls correctly added, at one column offset when handle is present
- var $tr = form.$('.o_field_x2many_list_row_add').closest('tr');
- assert.strictEqual($tr.find('td').length, 2);
- assert.strictEqual($tr.find('td:eq(0)').text(), "");
- assert.strictEqual($tr.find('td:eq(1)').text(), "Add a line");
-
- form.destroy();
- });
-
- QUnit.test('one2many form view with action button', function (assert) {
- // once the action button is clicked, the record is reloaded (via the
- // on_close handler, executed because the python method does not return
- // any action, or an ir.action.act_window_close) ; this test ensures that
- // it reloads the fields of the opened view (i.e. the form in this case).
- // See https://github.com/odoo/odoo/issues/24189
- assert.expect(7);
-
- var data = this.data;
- data.partner.records[0].p = [2];
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: data,
- res_id: 1,
- arch: '<form string="Partners">' +
- '<field name="p">' +
- '<tree><field name="display_name"/></tree>' +
- '<form>' +
- '<button type="action" string="Set Timmy"/>' +
- '<field name="timmy"/>' +
- '</form>' +
- '</field>' +
- '</form>',
- archs: {
- 'partner_type,false,list': '<tree><field name="display_name"/></tree>',
- },
- intercepts: {
- execute_action: function (ev) {
- data.partner.records[1].display_name = 'new name';
- data.partner.records[1].timmy = [12];
- ev.data.on_closed();
- },
- },
- viewOptions: {
- mode: 'edit',
- },
- });
-
- assert.strictEqual(form.$('.o_data_row').length, 1,
- "there should be one record in the one2many");
- assert.strictEqual(form.$('.o_data_cell').text(), 'second record',
- "initial display_name of o2m record should be correct");
-
- // open one2many record in form view
- form.$('.o_data_cell:first').click();
- assert.strictEqual($('.modal .o_form_view').length, 1,
- "should have opened the form view in a dialog");
- assert.strictEqual($('.modal .o_form_view .o_data_row').length, 0,
- "there should be no record in the many2many");
-
- // click on the action button
- $('.modal .o_form_view button').click();
- assert.strictEqual($('.modal .o_data_row').length, 1,
- "fields in the o2m form view should have been read");
- assert.strictEqual($('.modal .o_data_cell').text(), 'gold',
- "many2many subrecord should have been fetched");
-
- // save the dialog
- $('.modal .modal-footer .btn-primary').click();
-
- assert.strictEqual(form.$('.o_data_cell').text(), 'new name',
- "fields in the o2m list view should have been read as well");
-
- form.destroy();
- });
-
- QUnit.test('onchange affecting inline unopened list view', function (assert) {
- // when we got onchange result for fields of record that were not
- // already available because they were in a inline view not already
- // opened, in a given configuration the change were applied ignoring
- // existing data, thus a line of a one2many field inside a one2many
- // field could be duplicated unexplectedly
- assert.expect(5);
-
- var numUserOnchange = 0;
-
- this.data.user.onchanges = {
- partner_ids: function (obj) {
- if (numUserOnchange === 0) {
- // simulate proper server onchange after save of modal with new record
- obj.partner_ids = [
- [5],
- [1, 1, {
- display_name: 'first record',
- turtles: [
- [5],
- [1, 2, {'display_name': 'donatello'}],
- ],
- }],
- [1, 2, {
- display_name: 'second record',
- turtles: [
- [5],
- obj.partner_ids[1][2].turtles[0],
- ],
- }],
- ];
- }
- numUserOnchange++;
- },
- };
-
- var form = createView({
- View: FormView,
- model: 'user',
- data: this.data,
- arch: '<form><sheet><group>' +
- '<field name="partner_ids">' +
- '<form>'+
- '<field name="turtles">' +
- '<tree editable="bottom">' +
- '<field name="display_name"/>' +
- '</tree>' +
- '</field>' +
- '</form>' +
- '<tree>' +
- '<field name="display_name"/>' +
- '</tree>' +
- '</field>' +
- '</group></sheet></form>',
- res_id: 17,
- });
-
- // add a turtle on second partner
- form.$buttons.find('.o_form_button_edit').click();
- form.$('.o_data_row:eq(1)').click();
- $('.modal .o_field_x2many_list_row_add a').click();
- $('.modal input[name="display_name"]').val('michelangelo').change();
- $('.modal .btn-primary').click();
- // open first partner so changes from previous action are applied
- form.$('.o_data_row:eq(0)').click();
- $('.modal .btn-primary').click();
- form.$buttons.find('.o_form_button_save').click();
-
- assert.strictEqual(numUserOnchange, 2,
- 'there should 2 and only 2 onchange from closing the partner modal');
-
- form.$('.o_data_row:eq(0)').click();
- assert.strictEqual($('.modal .o_data_row').length, 1,
- 'only 1 turtle for first partner');
- assert.strictEqual($('.modal .o_data_row').text(), 'donatello',
- 'first partner turtle is donatello');
- $('.modal .o_form_button_cancel').click();
-
- form.$('.o_data_row:eq(1)').click();
- assert.strictEqual($('.modal .o_data_row').length, 1,
- 'only 1 turtle for second partner');
- assert.strictEqual($('.modal .o_data_row').text(), 'michelangelo',
- 'second partner turtle is michelangelo');
- $('.modal .o_form_button_cancel').click();
-
- form.destroy();
- });
-
- QUnit.test('click on URL should not open the record', function (assert) {
- assert.expect(2);
-
- this.data.partner.records[0].turtles = [1];
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="turtles">' +
- '<tree>' +
- '<field name="display_name" widget="email"/>' +
- '<field name="turtle_foo" widget="url"/>' +
- '</tree>' +
- '<form></form>' +
- '</field>' +
- '</form>',
- res_id: 1,
- });
-
- form.$('.o_email_cell a').click();
- assert.strictEqual($('.modal .o_form_view').length, 0,
- 'click should not open the modal');
-
- form.$('.o_url_cell a').click();
- assert.strictEqual($('.modal .o_form_view').length, 0,
- 'click should not open the modal');
- form.destroy();
- });
-
- QUnit.test('create and edit on m2o in o2m, and press ESCAPE', function (assert) {
- assert.expect(4);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form>' +
- '<field name="turtles">' +
- '<tree editable="top">' +
- '<field name="turtle_trululu"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- archs: {
- 'partner,false,form': '<form><field name="display_name"/></form>',
- },
- });
-
- form.$('.o_field_x2many_list_row_add a').click();
-
- assert.strictEqual(form.$('.o_selected_row').length, 1,
- "should have create a new row in edition");
-
- var $input = form.$('.o_field_widget[name="turtle_trululu"] input');
- $input.click();
- $input.autocomplete('widget').find('.o_m2o_dropdown_option').focus().click();
-
- assert.strictEqual($('.modal .o_form_view').length, 1,
- "should have opened a form view in a dialog");
-
- $('.modal .o_form_view .o_field_widget[name=display_name]').trigger($.Event('keydown', {
- which: $.ui.keyCode.ESCAPE,
- keyCode: $.ui.keyCode.ESCAPE,
- }));
-
- assert.strictEqual($('.modal .o_form_view').length, 0,
- "should have closed the dialog");
- assert.strictEqual(form.$('.o_selected_row').length, 1,
- "new row should still be present");
-
- form.destroy();
- });
-
- QUnit.test('one2many add a line should not crash if orderedResIDs is not set', function (assert) {
- // There is no assertion, the code will just crash before the bugfix.
- assert.expect(0);
-
- this.data.partner.records[0].turtles = [];
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<header>' +
- '<button name="action_invoice_open" type="object" string="Validate" class="oe_highlight"/>' +
- '</header>' +
- '<field name="turtles">' +
- '<tree editable="bottom">' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- viewOptions: {
- mode: 'edit',
- },
- intercepts: {
- execute_action: function (event) {
- event.data.on_fail();
- },
- },
- });
-
- $('button[name="action_invoice_open"]').click();
- form.$('.o_field_x2many_list_row_add a').click();
- form.destroy();
- });
-
- QUnit.test('one2many shortcut tab should not crash when there is no input widget', function (assert) {
- assert.expect(2);
-
- // create a one2many view which has no input (only 1 textarea in this case)
- 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="text"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- viewOptions: {
- mode: 'edit',
- },
- });
-
- // add a row, fill it, then trigger the tab shortcut
- form.$('.o_field_x2many_list_row_add a').click();
- form.$('.o_input[name="turtle_foo"]')
- .val('ninja')
- .trigger('input')
- .trigger($.Event('keydown', {
- which: $.ui.keyCode.TAB,
- keyCode: $.ui.keyCode.TAB,
- }));
-
- assert.strictEqual(form.$('.o_field_text').text(), 'blipninja',
- 'current line should be saved');
- assert.strictEqual(form.$('textarea.o_field_text').length, 1,
- 'new line should be created');
-
- form.destroy();
- });
-
- QUnit.test('one2many with onchange, required field, shortcut enter', function (assert) {
- assert.expect(5);
-
- this.data.turtle.onchanges = {
- turtle_foo: function () {},
- };
-
- var def;
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="turtles">' +
- '<tree editable="bottom">' +
- '<field name="turtle_foo" required="1"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- mockRPC: function (route, args) {
- var result = this._super.apply(this, arguments);
- if (args.method === 'onchange') {
- return $.when(def).then(_.constant(result));
- }
- return result;
- },
- // simulate what happens in the client:
- // the new value isn't notified directly to the model
- fieldDebounce: 5000,
- });
-
- var value = "hello";
-
- // add a new line
- form.$('.o_field_x2many_list_row_add a').click();
-
- // we want to add a delay to simulate an onchange
- def = $.Deferred();
-
- // write something in the field
- var $input = form.$('input[name="turtle_foo"]');
- $input.val(value).trigger('input');
-
- // trigger keydown ENTER
- $input.trigger($.Event('keydown', {
- keyCode: $.ui.keyCode.ENTER,
- which: $.ui.keyCode.ENTER,
- }));
-
- // check that nothing changed before the onchange finished
- assert.strictEqual($input.val(), value, "input content shouldn't change");
- assert.strictEqual(form.$('.o_data_row').length, 1,
- "should still contain only one row");
-
- // unlock onchange
- def.resolve();
-
- // check the current line is added with the correct content and a new line is editable
- assert.strictEqual(form.$('td.o_data_cell').text(), value);
- assert.strictEqual(form.$('input[name="turtle_foo"]').val(), '');
- assert.strictEqual(form.$('.o_data_row').length, 2,
- "should now contain two rows");
-
- form.destroy();
- });
-
- QUnit.test('no deadlock when leaving a one2many line with uncommitted changes', function (assert) {
- // Before unselecting a o2m line, field widgets are asked to commit their changes (new values
- // that they wouldn't have sent to the model yet). This test is added alongside a bug fix
- // ensuring that we don't end up in a deadlock when a widget actually has some changes to
- // commit at that moment.
- assert.expect(9);
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form>' +
- '<field name="turtles">' +
- '<tree editable="bottom">' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- mockRPC: function (route, args) {
- assert.step(args.method);
- return this._super.apply(this, arguments);
- },
- // we set a fieldDebounce to precisely mock the behavior of the webclient: changes are
- // not sent to the model at keystrokes, but when the input is left
- fieldDebounce: 5000,
- });
-
- form.$('.o_field_x2many_list_row_add a').click();
-
- form.$('input').val('some foo value').trigger('input');
-
- // click to add a second row to unselect the current one, then save
- form.$('.o_field_x2many_list_row_add a').click();
- form.$buttons.find('.o_form_button_save').click();
-
- assert.strictEqual(form.$('.o_form_readonly').length, 1,
- "form view should be in readonly");
- assert.strictEqual(form.$('.o_data_row').text(), 'some foo value',
- "foo field should have correct value");
- assert.verifySteps([
- 'default_get', // main record
- 'default_get', // line 1
- 'default_get', // line 2
- 'create',
- 'read', // main record
- 'read', // line 1
- ]);
-
- form.destroy();
- });
-
- QUnit.module('FieldMany2Many');
-
- QUnit.test('many2many kanban: edition', function (assert) {
- assert.expect(32);
-
- this.data.partner.records[0].timmy = [12, 14];
- this.data.partner_type.records.push({id: 15, display_name: "red", color: 6});
- this.data.partner_type.records.push({id: 18, display_name: "yellow", color: 4});
- this.data.partner_type.records.push({id: 21, display_name: "blue", color: 1});
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="timmy">' +
- '<kanban>' +
- '<field name="display_name"/>' +
- '<templates>' +
- '<t t-name="kanban-box">' +
- '<div class="oe_kanban_global_click">' +
- '<a t-if="!read_only_mode" type="delete" class="fa fa-times float-right delete_icon"/>' +
- '<span><t t-esc="record.display_name.value"/></span>' +
- '</div>' +
- '</t>' +
- '</templates>' +
- '</kanban>' +
- '<form string="Partners">' +
- '<field name="display_name"/>' +
- '</form>' +
- '</field>' +
- '</form>',
- archs: {
- 'partner_type,false,form': '<form string="Types"><field name="display_name"/></form>',
- 'partner_type,false,list': '<tree string="Types"><field name="display_name"/></tree>',
- 'partner_type,false,search': '<search string="Types">' +
- '<field name="name" string="Name"/>' +
- '</search>',
- },
- res_id: 1,
- mockRPC: function (route, args) {
- if (route === '/web/dataset/call_kw/partner_type/write') {
- assert.strictEqual(args.args[1].display_name, "new name", "should write 'new_name'");
- }
- if (route === '/web/dataset/call_kw/partner_type/create') {
- assert.strictEqual(args.args[0].display_name, "A new type", "should create 'A new type'");
- }
- if (route === '/web/dataset/call_kw/partner/write') {
- var commands = args.args[1].timmy;
- assert.strictEqual(commands.length, 1, "should have generated one command");
- assert.strictEqual(commands[0][0], 6, "generated command should be REPLACE WITH");
- // get the created type's id
- var createdType = _.findWhere(this.data.partner_type.records, {
- display_name: "A new type"
- });
- var ids = _.sortBy([12, 15, 18].concat(createdType.id), _.identity.bind(_));
- assert.ok(_.isEqual(_.sortBy(commands[0][2], _.identity.bind(_)), ids),
- "new value should be " + ids);
- }
- return this._super.apply(this, arguments);
- },
- });
-
- // the SelectCreateDialog requests the session, so intercept its custom
- // event to specify a fake session to prevent it from crashing
- testUtils.intercept(form, 'get_session', function (event) {
- event.data.callback({user_context: {}});
- });
-
- assert.ok(!form.$('.o_kanban_view .delete_icon').length,
- 'delete icon should not be visible in readonly');
- assert.ok(!form.$('.o_field_many2many .o-kanban-button-new').length,
- '"Add" button should not be visible in readonly');
-
- form.$buttons.find('.o_form_button_edit').click();
-
- assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 2,
- 'should contain 2 records');
- assert.strictEqual(form.$('.o_kanban_record:first() span').text(), 'gold',
- 'display_name of subrecord should be the one in DB');
- assert.ok(form.$('.o_kanban_view .delete_icon').length,
- 'delete icon should be visible in edit');
- assert.ok(form.$('.o_field_many2many .o-kanban-button-new').length,
- '"Add" button should be visible in edit');
- assert.strictEqual(form.$('.o_field_many2many .o-kanban-button-new').text().trim(), "Add",
- 'Create button should have "Add" label');
-
- // edit existing subrecord
- form.$('.oe_kanban_global_click:first()').click();
-
- $('.modal .o_form_view input').val('new name').trigger('input');
- $('.modal .modal-footer .btn-primary').click(); // save
- assert.strictEqual(form.$('.o_kanban_record:first() span').text(), 'new name',
- 'value of subrecord should have been updated');
-
- // add subrecords
- // -> single select
- form.$('.o_field_many2many .o-kanban-button-new').click();
- assert.ok($('.modal .o_list_view').length, "should have opened a list view in a modal");
- assert.strictEqual($('.modal .o_list_view tbody .o_list_record_selector').length, 3,
- "list view should contain 3 records");
- $('.modal .o_list_view tbody tr:contains(red)').click(); // select red
- assert.ok(!$('.modal .o_list_view').length, "should have closed the modal");
- assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 3,
- 'kanban should now contain 3 records');
- assert.ok(form.$('.o_kanban_record:contains(red)').length,
- 'record "red" should be in the kanban');
-
- // -> multiple select
- form.$('.o_field_many2many .o-kanban-button-new').click();
- assert.ok($('.modal .o_select_button').prop('disabled'), "select button should be disabled");
- assert.strictEqual($('.modal .o_list_view tbody .o_list_record_selector').length, 2,
- "list view should contain 2 records");
- $('.modal .o_list_view thead .o_list_record_selector input').click(); // select all
- $('.modal .o_select_button').click(); // validate selection
- assert.ok(!$('.modal .o_select_button').prop('disabled'), "select button should be enabled");
- assert.ok(!$('.modal .o_list_view').length, "should have closed the modal");
- assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 5,
- 'kanban should now contain 5 records');
- // -> created record
- form.$('.o_field_many2many .o-kanban-button-new').click();
- $('.modal .modal-footer .btn-primary:nth(1)').click(); // click on 'Create'
- assert.ok($('.modal .o_form_view.o_form_editable').length,
- "should have opened a form view in edit mode, in a modal");
- $('.modal .o_form_view input').val('A new type').trigger('input');
- $('.modal:nth(1) footer .btn-primary:first()').click(); // click on 'Save & Close'
- assert.ok(!$('.modal').length, "should have closed both modals");
- assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 6,
- 'kanban should now contain 6 records');
- assert.ok(form.$('.o_kanban_record:contains(A new type)').length,
- 'the newly created type should be in the kanban');
-
- // delete subrecords
- form.$('.o_kanban_record:contains(silver)').click();
- assert.strictEqual($('.modal .modal-footer .o_btn_remove').length, 1,
- 'There should be a modal having Remove Button');
- $('.modal .modal-footer .o_btn_remove').click(); // Click Remove Button of Formview Dialog
- assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 5,
- 'should contain 5 records');
- assert.ok(!form.$('.o_kanban_record:contains(silver)').length,
- 'the removed record should not be in kanban anymore');
-
- form.$('.o_kanban_record:contains(blue) .delete_icon').click();
- assert.strictEqual(form.$('.o_kanban_record:not(.o_kanban_ghost)').length, 4,
- 'should contain 4 records');
- assert.ok(!form.$('.o_kanban_record:contains(blue)').length,
- 'the removed record should not be in kanban anymore');
-
- // save the record
- form.$buttons.find('.o_form_button_save').click();
- form.destroy();
- });
-
- QUnit.test('many2many kanban(editable): properly handle create_text node option', function (assert) {
- assert.expect(1);
-
- this.data.partner.records[0].timmy = [12];
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="timmy" options="{\'create_text\': \'Add timmy\'}" mode="kanban">' +
- '<kanban>' +
- '<templates>' +
- '<t t-name="kanban-box">' +
- '<div class="oe_kanban_details">' +
- '<field name="display_name"/>' +
- '</div>' +
- '</t>' +
- '</templates>' +
- '</kanban>' +
- '</field>' +
- '</form>',
- res_id: 1,
- });
-
- form.$buttons.find('.o_form_button_edit').click();
- assert.strictEqual(form.$('.o_field_many2many[name="timmy"] .o-kanban-button-new').text().trim(),
- "Add timmy", "In M2M Kanban, Add button should have 'Add timmy' label");
-
- form.destroy();
- });
-
- QUnit.test('many2many kanban: create action disabled', function (assert) {
- assert.expect(4);
-
- this.data.partner.records[0].timmy = [12, 14];
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="timmy">' +
- '<kanban create="0">' +
- '<field name="display_name"/>' +
- '<templates>' +
- '<t t-name="kanban-box">' +
- '<div class="oe_kanban_global_click">' +
- '<a t-if="!read_only_mode" type="delete" class="fa fa-times float-right delete_icon"/>' +
- '<span><t t-esc="record.display_name.value"/></span>' +
- '</div>' +
- '</t>' +
- '</templates>' +
- '</kanban>' +
- '</field>' +
- '</form>',
- archs: {
- 'partner_type,false,list': '<tree><field name="name"/></tree>',
- 'partner_type,false,search': '<search>' +
- '<field name="display_name" string="Name"/>' +
- '</search>',
- },
- res_id: 1,
- session: {user_context: {}},
- });
-
- assert.ok(!form.$('.o-kanban-button-new').length,
- '"Add" button should not be available in readonly');
-
- form.$buttons.find('.o_form_button_edit').click();
-
- assert.ok(form.$('.o-kanban-button-new').length,
- '"Add" button should be available in edit');
- assert.ok(form.$('.o_kanban_view .delete_icon').length,
- 'delete icon should be visible in edit');
-
- form.$('.o-kanban-button-new').click(); // click on 'Add'
- assert.strictEqual($('.modal .modal-footer .btn-primary').length, 1, // only button 'Select'
- '"Create" button should not be available in the modal');
-
- form.destroy();
- });
-
- QUnit.test('many2many list (non editable): edition', function (assert) {
- assert.expect(27);
-
- this.data.partner.records[0].timmy = [12, 14];
- this.data.partner_type.records.push({id: 15, display_name: "bronze", color: 6});
- this.data.partner_type.fields.float_field = {string: 'Float', type: 'float'};
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="timmy">' +
- '<tree>' +
- '<field name="display_name"/><field name="float_field"/>' +
- '</tree>' +
- '<form string="Partners">' +
- '<field name="display_name"/>' +
- '</form>' +
- '</field>' +
- '</form>',
- archs: {
- 'partner_type,false,list': '<tree><field name="display_name"/></tree>',
- 'partner_type,false,search': '<search><field name="display_name"/></search>',
- },
- res_id: 1,
- mockRPC: function (route, args) {
- if (args.method !== 'load_views') {
- assert.step(_.last(route.split('/')));
- }
- if (args.method === 'write' && args.model === 'partner') {
- assert.deepEqual(args.args[1].timmy, [
- [6, false, [12, 15]],
- ]);
- }
- return this._super.apply(this, arguments);
- },
- });
-
- assert.ok(!form.$('.o_list_record_remove').length,
- 'delete icon should not be visible in readonly');
- assert.ok(!form.$('.o_field_x2many_list_row_add').length,
- '"Add an item" should not be visible in readonly');
-
- form.$buttons.find('.o_form_button_edit').click();
-
- assert.strictEqual(form.$('.o_list_view td.o_list_number').length, 2,
- 'should contain 2 records');
- assert.strictEqual(form.$('.o_list_view tbody td:first()').text(), 'gold',
- 'display_name of first subrecord should be the one in DB');
- assert.ok(form.$('.o_list_record_remove').length,
- 'delete icon should be visible in edit');
- assert.ok(form.$('.o_field_x2many_list_row_add').length,
- '"Add an item" should be visible in edit');
-
- // edit existing subrecord
- form.$('.o_list_view tbody tr:first()').click();
-
- $('.modal .o_form_view input').val('new name').trigger('input');
- $('.modal .modal-footer .btn-primary').click(); // save
- assert.strictEqual(form.$('.o_list_view tbody td:first()').text(), 'new name',
- 'value of subrecord should have been updated');
-
- // add new subrecords
- form.$('.o_field_x2many_list_row_add a').click();
- assert.strictEqual($('.modal .o_list_view').length, 1,
- "a modal should be open");
- assert.strictEqual($('.modal .o_list_view .o_data_row').length, 1,
- "the list should contain one row");
- $('.modal .o_list_view .o_data_row').click(); // select a record
- assert.strictEqual($('.modal .o_list_view').length, 0,
- "the modal should be closed");
- assert.strictEqual(form.$('.o_list_view td.o_list_number').length, 3,
- 'should contain 3 subrecords');
-
- // remove subrecords
- form.$('.o_list_record_remove:nth(1)').click();
- assert.strictEqual(form.$('.o_list_view td.o_list_number').length, 2,
- 'should contain 2 subrecords');
- assert.strictEqual(form.$('.o_list_view .o_data_row td:first').text(), 'new name',
- 'the updated row still has the correct values');
-
- // save
- form.$buttons.find('.o_form_button_save').click();
- assert.strictEqual(form.$('.o_list_view td.o_list_number').length, 2,
- 'should contain 2 subrecords');
- assert.strictEqual(form.$('.o_list_view .o_data_row td:first').text(),
- 'new name', 'the updated row still has the correct values');
-
- assert.verifySteps([
- 'read', // main record
- 'read', // relational field
- 'read', // relational record in dialog
- 'write', // save relational record from dialog
- 'read', // relational field (updated)
- 'search_read', // list view in dialog
- 'read', // relational field (updated)
- 'write', // save main record
- 'read', // main record
- 'read', // relational field
- ]);
-
- form.destroy();
- });
-
- QUnit.test('many2many list (editable): edition', function (assert) {
- assert.expect(31);
-
- this.data.partner.records[0].timmy = [12, 14];
- this.data.partner_type.records.push({id: 15, display_name: "bronze", color: 6});
- this.data.partner_type.fields.float_field = {string: 'Float', type: 'float'};
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="timmy">' +
- '<tree editable="top">' +
- '<field name="display_name"/><field name="float_field"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- archs: {
- 'partner_type,false,list': '<tree><field name="display_name"/></tree>',
- 'partner_type,false,search': '<search><field name="display_name"/></search>',
- },
- mockRPC: function (route, args) {
- if (args.method !== 'load_views') {
- assert.step(_.last(route.split('/')));
- }
- if (args.method === 'write') {
- assert.deepEqual(args.args[1].timmy, [
- [6, false, [12, 15]],
- [1, 12, {display_name: 'new name'}],
- ]);
- }
- return this._super.apply(this, arguments);
- },
- res_id: 1,
- });
-
- assert.ok(!form.$('.o_list_record_remove').length,
- 'delete icon should not be visible in readonly');
- assert.ok(!form.$('.o_field_x2many_list_row_add').length,
- '"Add an item" should not be visible in readonly');
-
- form.$buttons.find('.o_form_button_edit').click();
-
- assert.strictEqual(form.$('.o_list_view td.o_list_number').length, 2,
- 'should contain 2 records');
- assert.strictEqual(form.$('.o_list_view tbody td:first()').text(), 'gold',
- 'display_name of first subrecord should be the one in DB');
- assert.ok(form.$('.o_list_record_remove').length,
- 'delete icon should be visible in edit');
- assert.ok(form.$('td.o_list_record_remove button').first().hasClass('fa fa-times'),
- "should have X icons to remove (unlink) records");
- assert.ok(form.$('.o_field_x2many_list_row_add').length,
- '"Add an item" should not visible in edit');
-
- // edit existing subrecord
- form.$('.o_list_view tbody td:first()').click();
- assert.ok(!$('.modal').length,
- 'in edit, clicking on a subrecord should not open a dialog');
- assert.ok(form.$('.o_list_view tbody tr:first()').hasClass('o_selected_row'),
- 'first row should be in edition');
- form.$('.o_list_view input:first()').val('new name').trigger('input');
- assert.ok(form.$('.o_list_view .o_data_row:first').hasClass('o_selected_row'),
- 'first row should still be in edition');
- assert.strictEqual(form.$('.o_list_view input[name=display_name]').get(0),
- document.activeElement, 'edited field should still have the focus');
- form.$el.click(); // click outside the list to validate the row
- assert.ok(!form.$('.o_list_view tbody tr:first').hasClass('o_selected_row'),
- 'first row should not be in edition anymore');
- assert.strictEqual(form.$('.o_list_view tbody td:first()').text(), 'new name',
- 'value of subrecord should have been updated');
- assert.verifySteps(['read', 'read']);
-
- // add new subrecords
- form.$('.o_field_x2many_list_row_add a').click();
- assert.strictEqual($('.modal .o_list_view').length, 1,
- "a modal should be open");
- assert.strictEqual($('.modal .o_list_view .o_data_row').length, 1,
- "the list should contain one row");
- $('.modal .o_list_view .o_data_row').click(); // select a record
- assert.strictEqual($('.modal .o_list_view').length, 0,
- "the modal should be closed");
- assert.strictEqual(form.$('.o_list_view td.o_list_number').length, 3,
- 'should contain 3 subrecords');
-
- // remove subrecords
- form.$('.o_list_record_remove:nth(1)').click();
- assert.strictEqual(form.$('.o_list_view td.o_list_number').length, 2,
- 'should contain 2 subrecord');
- assert.strictEqual(form.$('.o_list_view tbody .o_data_row td:first').text(),
- 'new name', 'the updated row still has the correct values');
-
- // save
- form.$buttons.find('.o_form_button_save').click();
- assert.strictEqual(form.$('.o_list_view td.o_list_number').length, 2,
- 'should contain 2 subrecords');
- assert.strictEqual(form.$('.o_list_view .o_data_row td:first').text(),
- 'new name', 'the updated row still has the correct values');
-
- assert.verifySteps([
- 'read', // main record
- 'read', // relational field
- 'search_read', // list view in dialog
- 'read', // relational field (updated)
- 'write', // save main record
- 'read', // main record
- 'read', // relational field
- ]);
-
- form.destroy();
- });
-
- QUnit.test('many2many: create & delete attributes', function (assert) {
- assert.expect(4);
-
- this.data.partner.records[0].timmy = [12, 14];
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="timmy">' +
- '<tree create="true" delete="true">' +
- '<field name="color"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- });
-
- form.$buttons.find('.o_form_button_edit').click();
-
- assert.strictEqual(form.$('.o_field_x2many_list_row_add').length, 1, "should have the 'Add an item' link");
- assert.strictEqual(form.$('.o_list_record_remove').length, 2, "should have the 'Add an item' link");
-
- form.destroy();
-
- form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="timmy">' +
- '<tree create="false" delete="false">' +
- '<field name="color"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- });
-
- form.$buttons.find('.o_form_button_edit').click();
-
- assert.strictEqual(form.$('.o_field_x2many_list_row_add').length, 0, "should not have the 'Add an item' link");
- assert.strictEqual(form.$('.o_list_record_remove').length, 0, "should not have the 'Add an item' link");
-
- form.destroy();
- });
-
- QUnit.test('many2many list: create action disabled', function (assert) {
- assert.expect(2);
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="timmy">' +
- '<tree create="0">' +
- '<field name="name"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- });
-
- assert.ok(!form.$('.o_field_x2many_list_row_add').length,
- '"Add an item" link should not be available in readonly');
-
- form.$buttons.find('.o_form_button_edit').click();
-
- assert.ok(!form.$('.o_field_x2many_list_row_add').length,
- '"Add an item" link should not be available in edit either');
-
- form.destroy();
- });
-
- QUnit.test('many2many list: list of id as default value', function (assert) {
- assert.expect(1);
-
- this.data.partner.fields.turtles.default = [2, 3];
- this.data.partner.fields.turtles.type = "many2many";
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="turtles">' +
- '<tree>' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- });
-
- assert.strictEqual(form.$('td.o_data_cell').text(), "blipkawa",
- "should have loaded default data");
-
- form.destroy();
- });
-
- QUnit.test('many2many checkboxes with default values', function (assert) {
- assert.expect(7);
-
- this.data.partner.fields.turtles.default = [3];
- this.data.partner.fields.turtles.type = "many2many";
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="turtles" widget="many2many_checkboxes">' +
- '</field>' +
- '</form>',
- mockRPC: function (route, args) {
- if (args.method === 'create') {
- assert.deepEqual(args.args[0].turtles, [[6, false, [1]]],
- "correct values should have been sent to create");
- }
- return this._super.apply(this, arguments);
- }
- });
-
- assert.notOk(form.$('.custom-checkbox input').eq(0).prop('checked'),
- "first checkbox should not be checked");
- assert.notOk(form.$('.custom-checkbox input').eq(1).prop('checked'),
- "second checkbox should not be checked");
- assert.ok(form.$('.custom-checkbox input').eq(2).prop('checked'),
- "third checkbox should be checked");
-
- form.$('.custom-checkbox input:checked').click(); // Uncheck default record
- form.$('.custom-checkbox input').first().click(); // Check first record
- form.$('.custom-checkbox input').first().click(); // Uncheck first record
- form.$('.custom-checkbox input').first().click(); // Recheck first record
-
- assert.ok(form.$('.custom-checkbox input').eq(0).prop('checked'),
- "first checkbox should be checked");
- assert.notOk(form.$('.custom-checkbox input').eq(1).prop('checked'),
- "second checkbox should not be checked");
- assert.notOk(form.$('.custom-checkbox input').eq(2).prop('checked'),
- "third checkbox should not be checked");
-
- form.$buttons.find('.o_form_button_save').click();
-
- form.destroy();
- });
-
- QUnit.test('many2many checkboxes with default values', function (assert) {
- assert.expect(7);
-
- this.data.partner.fields.turtles.default = [3];
- this.data.partner.fields.turtles.type = "many2many";
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="turtles" widget="many2many_checkboxes">' +
- '</field>' +
- '</form>',
- mockRPC: function (route, args) {
- if (args.method === 'create') {
- assert.deepEqual(args.args[0].turtles, [[6, false, [1]]],
- "correct values should have been sent to create");
- }
- return this._super.apply(this, arguments);
- }
- });
-
- assert.notOk(form.$('.custom-checkbox input').eq(0).prop('checked'),
- "first checkbox should not be checked");
- assert.notOk(form.$('.custom-checkbox input').eq(1).prop('checked'),
- "second checkbox should not be checked");
- assert.ok(form.$('.custom-checkbox input').eq(2).prop('checked'),
- "third checkbox should be checked");
-
- form.$('.custom-checkbox input:checked').click(); // Uncheck default record
- form.$('.custom-checkbox input').first().click(); // Check first record
- form.$('.custom-checkbox input').first().click(); // Uncheck first record
- form.$('.custom-checkbox input').first().click(); // Recheck first record
-
- assert.ok(form.$('.custom-checkbox input').eq(0).prop('checked'),
- "first checkbox should be checked");
- assert.notOk(form.$('.custom-checkbox input').eq(1).prop('checked'),
- "second checkbox should not be checked");
- assert.notOk(form.$('.custom-checkbox input').eq(2).prop('checked'),
- "third checkbox should not be checked");
-
- form.$buttons.find('.o_form_button_save').click();
-
- form.destroy();
- });
-
- QUnit.test('many2many list with x2many: add a record', function (assert) {
- assert.expect(18);
-
- this.data.partner_type.fields.m2m = {
- string: "M2M", type: "many2many", relation: 'turtle',
- };
- this.data.partner_type.records[0].m2m = [1, 2];
- this.data.partner_type.records[1].m2m = [2, 3];
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="timmy"/>' +
- '</form>',
- res_id: 1,
- archs: {
- 'partner_type,false,list': '<tree>' +
- '<field name="display_name"/>' +
- '<field name="m2m" widget="many2many_tags"/>' +
- '</tree>',
- 'partner_type,false,search': '<search>' +
- '<field name="display_name" string="Name"/>' +
- '</search>',
- },
- mockRPC: function (route, args) {
- if (args.method !== 'load_views') {
- assert.step(_.last(route.split('/')) + ' on ' + args.model);
- }
- if (args.model === 'turtle') {
- assert.step(args.args[0]); // the read ids
- }
- return this._super.apply(this, arguments);
- },
- viewOptions: {
- mode: 'edit',
- },
- });
-
- form.$('.o_field_x2many_list_row_add a').click();
- $('.modal .o_data_row:first').click(); // add a first record to the relation
-
- assert.strictEqual(form.$('.o_data_row').length, 1,
- "the record should have been added to the relation");
- assert.strictEqual(form.$('.o_data_row:first .o_badge_text').text(), 'leonardodonatello',
- "inner m2m should have been fetched and correctly displayed");
-
- form.$('.o_field_x2many_list_row_add a').click();
- $('.modal .o_data_row:first').click(); // add a second record to the relation
-
- assert.strictEqual(form.$('.o_data_row').length, 2,
- "the second record should have been added to the relation");
- assert.strictEqual(form.$('.o_data_row:nth(1) .o_badge_text').text(), 'donatelloraphael',
- "inner m2m should have been fetched and correctly displayed");
-
- assert.verifySteps([
- 'read on partner',
- 'search_read on partner_type',
- 'read on turtle',
- [1, 2, 3],
- 'read on partner_type',
- 'read on turtle',
- [1, 2],
- 'search_read on partner_type',
- 'read on turtle',
- [2, 3],
- 'read on partner_type',
- 'read on turtle',
- [2, 3],
- ]);
-
- form.destroy();
- });
-
- QUnit.test('many2many with a domain', function (assert) {
- // The domain specified on the field should not be replaced by the potential
- // domain the user writes in the dialog, they should rather be concatenated
- assert.expect(2);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="timmy" domain="[[\'display_name\', \'=\', \'gold\']]"/>' +
- '</form>',
- res_id: 1,
- archs: {
- 'partner_type,false,list': '<tree>' +
- '<field name="display_name"/>' +
- '</tree>',
- 'partner_type,false,search': '<search>' +
- '<field name="display_name" string="Name"/>' +
- '</search>',
- },
- viewOptions: {
- mode: 'edit',
- },
- });
-
- form.$('.o_field_x2many_list_row_add a').click();
- assert.strictEqual($('.modal .o_data_row').length, 1,
- "should contain only one row (gold)");
-
- $('.modal .o_searchview_input').trigger({type: 'keypress', which: 115}); // s
- $('.modal .o_searchview_input').trigger({type: 'keydown', which: 13}); // enter
-
- assert.strictEqual($('.modal .o_data_row').length, 0, "should contain no row");
-
- form.destroy();
- });
-
- QUnit.test('many2many list with onchange and edition of a record', function (assert) {
- assert.expect(8);
-
- this.data.partner.fields.turtles.type = "many2many";
- this.data.partner.onchanges.turtles = function () {};
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="turtles">' +
- '<tree>' +
- '<field name="turtle_foo"/>' +
- '</tree>' +
- '</field>' +
- '</form>',
- res_id: 1,
- archs: {
- 'turtle,false,form': '<form string="Turtle Power"><field name="turtle_bar"/></form>',
- },
- mockRPC: function (route, args) {
- assert.step(args.method);
- return this._super.apply(this, arguments);
- },
- });
-
- form.$buttons.find('.o_form_button_edit').click();
- form.$('td.o_data_cell:first').click();
-
- $('.modal-body input[type="checkbox"]').click();
- $('.modal .modal-footer .btn-primary').first().click();
-
- // there is nothing left to save -> should not do a 'write' RPC
- form.$buttons.find('.o_form_button_save').click();
-
- assert.verifySteps([
- 'read', // read initial record (on partner)
- 'read', // read many2many turtles
- 'load_views', // load arch of turtles form view
- 'read', // read missing field when opening record in modal form view
- 'write', // when saving the modal
- 'onchange', // onchange should be triggered on partner
- 'read', // reload many2many
- ]);
-
- form.destroy();
- });
-
- QUnit.test('onchange with 40+ commands for a many2many', function (assert) {
- // this test ensures that the basic_model correctly handles more LINK_TO
- // commands than the limit of the dataPoint (40 for x2many kanban)
- assert.expect(23);
-
- // create a lot of partner_types that will be linked by the onchange
- var commands = [[5]];
- for (var i = 0; i < 45; i++) {
- var id = 100 + i;
- this.data.partner_type.records.push({id: id, display_name: "type " + id});
- commands.push([4, id]);
- }
- this.data.partner.onchanges = {
- foo: function (obj) {
- obj.timmy = commands;
- },
- };
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch:'<form string="Partners">' +
- '<field name="foo"/>' +
- '<field name="timmy">' +
- '<kanban>' +
- '<field name="display_name"/>' +
- '<templates>' +
- '<t t-name="kanban-box">' +
- '<div><t t-esc="record.display_name.value"/></div>' +
- '</t>' +
- '</templates>' +
- '</kanban>' +
- '</field>' +
- '</form>',
- res_id: 1,
- mockRPC: function (route, args) {
- assert.step(args.method);
- if (args.method === 'write') {
- assert.strictEqual(args.args[1].timmy[0][0], 6,
- "should send a command 6");
- assert.strictEqual(args.args[1].timmy[0][2].length, 45,
- "should replace with 45 ids");
- }
- return this._super.apply(this, arguments);
- },
- viewOptions: {
- mode: 'edit',
- },
- });
-
- assert.verifySteps(['read']);
-
- form.$('.o_field_widget[name=foo]').val('trigger onchange').trigger('input');
-
- assert.verifySteps(['read', 'onchange', 'read']);
- assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(),
- '1-40 / 45', "pager should be correct");
- assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40,
- 'there should be 40 records displayed on page 1');
-
- form.$('.o_pager_next').click();
- assert.verifySteps(['read', 'onchange', 'read', 'read']);
- assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(),
- '41-45 / 45', "pager should be correct");
- assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 5,
- 'there should be 5 records displayed on page 2');
-
- form.$buttons.find('.o_form_button_save').click();
-
- assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(),
- '1-40 / 45', "pager should be correct");
- assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40,
- 'there should be 40 records displayed on page 1');
-
- form.$('.o_pager_next').click();
- assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(),
- '41-45 / 45', "pager should be correct");
- assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 5,
- 'there should be 5 records displayed on page 2');
-
- form.$('.o_pager_next').click(); // back to page 1
- assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(),
- '1-40 / 45', "pager should be correct");
- assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40,
- 'there should be 40 records displayed on page 1');
-
- form.destroy();
- });
-
- QUnit.test('default_get, onchange, onchange on m2m', function (assert) {
- assert.expect(1);
-
- this.data.partner.onchanges.int_field = function (obj) {
- if (obj.int_field === 2) {
- assert.deepEqual(obj.timmy, [
- [6, false, [12]],
- [1, 12, {display_name: 'gold'}]
- ]);
- }
- obj.timmy = [
- [5],
- [1, 12, {display_name: 'gold'}]
- ];
- };
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form>' +
- '<sheet>' +
- '<field name="timmy">' +
- '<tree>' +
- '<field name="display_name"/>' +
- '</tree>' +
- '</field>' +
- '<field name="int_field"/>' +
- '</sheet>' +
- '</form>',
- });
-
- form.$('.o_field_widget[name=int_field]').val(2).trigger('input');
- form.destroy();
- });
-
- QUnit.test('widget many2many_tags', function (assert) {
- assert.expect(1);
- this.data.turtle.records[0].partner_ids = [2];
-
- var form = createView({
- View: FormView,
- model: 'turtle',
- data: this.data,
- arch:'<form string="Turtles">' +
- '<sheet>' +
- '<field name="display_name"/>' +
- '<field name="partner_ids" widget="many2many_tags"/>' +
- '</sheet>' +
- '</form>',
- res_id: 1,
- });
-
- assert.deepEqual(
- form.$('.o_field_many2manytags.o_field_widget .badge .o_badge_text').attr('title'),
- 'second record', 'the title should be filled in'
- );
-
- form.destroy();
- });
-
QUnit.module('FieldStatus');
QUnit.test('static statusbar widget on many2one field', function (assert) {
@@ -13544,90 +2126,6 @@ QUnit.module('relational_fields', {
form.destroy();
});
- QUnit.test('one2many with extra field from server not in (inline) form', function (assert) {
- assert.expect(1);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="p" >' +
- '<tree>' +
- '<field name="datetime"/>' +
- '<field name="display_name"/>' +
- '</tree>' +
- '<form>' +
- '<field name="display_name"/>' +
- '</form>' +
- '</field>' +
- '</form>',
- res_id: 1,
- viewOptions: {
- mode: 'edit',
- },
- });
-
- var x2mList = form.$('.o_field_x2many_list[name=p]');
-
- // Add a record in the list
- x2mList.find('.o_field_x2many_list_row_add a').click();
-
- var modal = $('.modal-lg');
-
- var nameInput = modal.find('input.o_input[name=display_name]');
- nameInput.val('michelangelo').trigger('input');
-
- // Save the record in the modal (though it is still virtual)
- modal.find('.btn-primary').first().click();
-
- assert.equal(x2mList.find('.o_data_row').length, 1,
- 'There should be 1 records in the x2m list');
-
- form.destroy();
- });
-
- QUnit.test('one2many with extra X2many field from server not in inline form', function (assert) {
- assert.expect(1);
-
- var form = createView({
- View: FormView,
- model: 'partner',
- data: this.data,
- arch: '<form string="Partners">' +
- '<field name="p" >' +
- '<tree>' +
- '<field name="turtles"/>' +
- '<field name="display_name"/>' +
- '</tree>' +
- '<form>' +
- '<field name="display_name"/>' +
- '</form>' +
- '</field>' +
- '</form>',
- res_id: 1,
- viewOptions: {
- mode: 'edit',
- },
- });
-
- var x2mList = form.$('.o_field_x2many_list[name=p]');
-
- // Add a first record in the list
- x2mList.find('.o_field_x2many_list_row_add a').click();
-
- // Save & New
- $('.modal-lg').find('.btn-primary').eq(1).click();
-
- // Save & Close
- $('.modal-lg').find('.btn-primary').eq(0).click();
-
- assert.equal(x2mList.find('.o_data_row').length, 2,
- 'There should be 2 records in the x2m list');
-
- form.destroy();
- });
-
QUnit.test('one2many invisible depends on parent field', function (assert) {
assert.expect(4);
diff --git a/addons/web/static/tests/helpers/test_utils.js b/addons/web/static/tests/helpers/test_utils.js
index f20f183d799..ab46c05cfa8 100644
--- a/addons/web/static/tests/helpers/test_utils.js
+++ b/addons/web/static/tests/helpers/test_utils.js
@@ -98,7 +98,7 @@ var createActionManager = function (params) {
return this._super.apply(this, arguments);
},
});
- addMockEnvironment(widget, _.defaults(params, {debounce: false}));
+ addMockEnvironment(widget, _.defaults(params, { debounce: false }));
widget.prependTo($target);
widget.$el.addClass('o_web_client');
if (config.device.isMobile) {
@@ -282,7 +282,7 @@ function createAsyncView(params) {
var fragment = document.createDocumentFragment();
return view.appendTo(fragment).then(function () {
dom.append($content, fragment, {
- callbacks: [{widget: view}],
+ callbacks: [{ widget: view }],
in_DOM: true,
});
view.$el.on('click', 'a', function (ev) {
@@ -323,24 +323,52 @@ function patchDate(year, month, day, hours, minutes, seconds) {
var fakeDate = new RealDate(year, month, day, hours, minutes, seconds);
var timeInterval = actualDate.getTime() - (fakeDate.getTime());
- window.Date = function Date() {
- if (arguments.length > 0) {
- return RealDate.apply(this, arguments);
- } else {
- var date = new RealDate();
- var time = date.getTime();
- time -= timeInterval;
- date.setTime(time);
- return date;
+ Date = (function (NativeDate) {
+ function Date(Y, M, D, h, m, s, ms) {
+ var length = arguments.length;
+ if (arguments.length > 0) {
+ var date = length == 1 && String(Y) === Y ? // isString(Y)
+ // We explicitly pass it through parse:
+ new NativeDate(Date.parse(Y)) :
+ // We have to manually make calls depending on argument
+ // length here
+ length >= 7 ? new NativeDate(Y, M, D, h, m, s, ms) :
+ length >= 6 ? new NativeDate(Y, M, D, h, m, s) :
+ length >= 5 ? new NativeDate(Y, M, D, h, m) :
+ length >= 4 ? new NativeDate(Y, M, D, h) :
+ length >= 3 ? new NativeDate(Y, M, D) :
+ length >= 2 ? new NativeDate(Y, M) :
+ length >= 1 ? new NativeDate(Y) :
+ new NativeDate();
+ // Prevent mixups with unfixed Date object
+ date.constructor = Date;
+ return date;
+ } else {
+ var date = new NativeDate();
+ var time = date.getTime();
+ time -= timeInterval;
+ date.setTime(time);
+ return date;
+ }
}
- };
- _.mapObject(RealDate, function (val, key) {
- window.Date[key] = val;
- });
- window.Date.prototype = RealDate.prototype;
+ // Copy any custom methods a 3rd party library may have added
+ for (var key in NativeDate) {
+ Date[key] = NativeDate[key];
+ }
+
+ // Copy "native" methods explicitly; they may be non-enumerable
+ Date.now = NativeDate.now;
+ Date.UTC = NativeDate.UTC;
+ Date.prototype = NativeDate.prototype;
+ Date.prototype.constructor = Date;
+
+ // Upgrade Date.parse to handle simplified ISO 8601 strings
+ Date.parse = NativeDate.parse;
+ return Date;
+ })(Date);
- return function () {window.Date = RealDate;};
+ return function () { window.Date = RealDate; };
}
/**
@@ -400,7 +428,7 @@ function addMockEnvironment(widget, params) {
var Server = MockServer;
params.services = params.services || {};
if (params.mockRPC) {
- Server = MockServer.extend({_performRpc: params.mockRPC});
+ Server = MockServer.extend({ _performRpc: params.mockRPC });
}
if (params.debug) {
observe(widget);
@@ -666,17 +694,17 @@ function dragAndDrop($el, $to, options) {
toOffset.top += position.top;
toOffset.left += position.left;
} else {
- toOffset.top += $to.outerHeight()/2;
- toOffset.left += $to.outerWidth()/2;
+ toOffset.top += $to.outerHeight() / 2;
+ toOffset.left += $to.outerWidth() / 2;
var vertical_offset = (toOffset.top < elementCenter.top) ? -1 : 1;
if (position === 'top') {
- toOffset.top -= $to.outerHeight()/2 + vertical_offset;
+ toOffset.top -= $to.outerHeight() / 2 + vertical_offset;
} else if (position === 'bottom') {
- toOffset.top += $to.outerHeight()/2 - vertical_offset;
+ toOffset.top += $to.outerHeight() / 2 - vertical_offset;
} else if (position === 'left') {
- toOffset.left -= $to.outerWidth()/2;
+ toOffset.left -= $to.outerWidth() / 2;
} else if (position === 'right') {
- toOffset.left += $to.outerWidth()/2;
+ toOffset.left += $to.outerWidth() / 2;
}
}
@@ -688,8 +716,8 @@ function dragAndDrop($el, $to, options) {
}
$el.trigger($.Event("mouseenter"));
if (!(options && options.continueMove)) {
- elementCenter.left += $el.outerWidth()/2;
- elementCenter.top += $el.outerHeight()/2;
+ elementCenter.left += $el.outerWidth() / 2;
+ elementCenter.top += $el.outerHeight() / 2;
$el.trigger($.Event("mousedown", {
which: 1,
@@ -733,7 +761,7 @@ function triggerMouseEvent($el, type) {
var pos = $el.offset();
var e = new $.Event(type);
e.pageX = e.layerX = e.screenX = pos.left;
- e.pageY = e.layerY = e.screenY =pos.top;
+ e.pageY = e.layerY = e.screenY = pos.top;
e.which = 1;
$el.trigger(e);
}
@@ -747,9 +775,9 @@ function triggerMouseEvent($el, type) {
* @param {integer} y
* @param {string} type a mouse event type, such as 'mousedown' or 'mousemove'
*/
-function triggerPositionalMouseEvent(x, y, type){
+function triggerPositionalMouseEvent(x, y, type) {
var ev = document.createEvent("MouseEvent");
- var el = document.elementFromPoint(x,y);
+ var el = document.elementFromPoint(x, y);
ev.initMouseEvent(
type,
true /* bubble */,
@@ -774,7 +802,7 @@ function triggerKeypressEvent(char) {
} else {
keycode = char.charCodeAt(0);
}
- return $('body').trigger($.Event('keypress', {which: keycode, keyCode: keycode}));
+ return $('body').trigger($.Event('keypress', { which: keycode, keyCode: keycode }));
}
/**
@@ -805,7 +833,7 @@ function removeSrcAttribute(el, widget) {
node.setAttribute('src', 'about:blank');
}
if (widget) {
- widget._rpc({route: src});
+ widget._rpc({ route: src });
}
}
}
diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js
index 84d63fa4f67..435c13cd3f1 100644
--- a/addons/web/static/tests/views/kanban_tests.js
+++ b/addons/web/static/tests/views/kanban_tests.js
@@ -2297,6 +2297,138 @@ QUnit.module('Views', {
kanban.destroy();
});
+ QUnit.test('o2m loaded in only one batch', function (assert) {
+ assert.expect(9);
+
+ this.data.subtask = {
+ fields: {
+ name: {string: 'Name', type: 'char'}
+ },
+ records: [
+ {id: 1, name: "subtask #1"},
+ {id: 2, name: "subtask #2"},
+ ]
+ };
+ this.data.partner.fields.subtask_ids = {
+ string: 'Subtasks',
+ type: 'one2many',
+ relation: 'subtask'
+ };
+ this.data.partner.records[0].subtask_ids = [1];
+ this.data.partner.records[1].subtask_ids = [2];
+
+ var kanban = createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test">' +
+ '<field name="product_id"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="subtask_ids" widget="many2many_tags"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ kanban.reload();
+ assert.verifySteps([
+ 'read_group',
+ '/web/dataset/search_read',
+ '/web/dataset/search_read',
+ 'read',
+ 'read_group',
+ '/web/dataset/search_read',
+ '/web/dataset/search_read',
+ 'read',
+ ]);
+ kanban.destroy();
+ });
+
+ QUnit.test('m2m loaded in only one batch', function (assert) {
+ assert.expect(9);
+
+ var kanban = createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test">' +
+ '<field name="product_id"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="category_ids" widget="many2many_tags"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ kanban.reload();
+ assert.verifySteps([
+ 'read_group',
+ '/web/dataset/search_read',
+ '/web/dataset/search_read',
+ 'read',
+ 'read_group',
+ '/web/dataset/search_read',
+ '/web/dataset/search_read',
+ 'read',
+ ]);
+ kanban.destroy();
+ });
+
+ QUnit.test('fetch reference in only one batch', function (assert) {
+ assert.expect(9);
+
+ this.data.partner.records[0].ref_product = 'product,3';
+ this.data.partner.records[1].ref_product = 'product,5';
+ this.data.partner.fields.ref_product = {
+ string: "Reference Field",
+ type: 'reference',
+ };
+
+ var kanban = createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test">' +
+ '<field name="product_id"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div class="oe_kanban_global_click">' +
+ '<field name="ref_product"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ kanban.reload();
+ assert.verifySteps([
+ 'read_group',
+ '/web/dataset/search_read',
+ '/web/dataset/search_read',
+ 'name_get',
+ 'read_group',
+ '/web/dataset/search_read',
+ '/web/dataset/search_read',
+ 'name_get',
+ ]);
+ kanban.destroy();
+ });
+
QUnit.test('can drag and drop a record from one column to the next', function (assert) {
assert.expect(9);
diff --git a/addons/web/static/tests/widgets/model_field_selector_tests.js b/addons/web/static/tests/widgets/model_field_selector_tests.js
index 74d83b84ca0..23b5ed5d05c 100644
--- a/addons/web/static/tests/widgets/model_field_selector_tests.js
+++ b/addons/web/static/tests/widgets/model_field_selector_tests.js
@@ -191,6 +191,37 @@ QUnit.module('ModelFieldSelector', {
fieldSelector.destroy();
});
+
+ QUnit.test("use the `showSearchInput` option", function (assert) {
+ assert.expect(6);
+
+ var $target = $("#qunit-fixture");
+
+ // Create the field selector and its mock environment
+ var fieldSelector = new ModelFieldSelector(null, "partner", [], {
+ readonly: false,
+ showSearchInput: true,
+ });
+ testUtils.addMockEnvironment(fieldSelector, {data: this.data});
+ fieldSelector.appendTo($target);
+
+ fieldSelector.$el.trigger('focusin');
+ var $fieldSelectorPopover = fieldSelector.$(".o_field_selector_popover:visible");
+ var $searchInput = $fieldSelectorPopover.find(".o_field_selector_search input");
+ assert.strictEqual($searchInput.length, 1, "there should be a search input");
+
+ // without search
+ assert.strictEqual($fieldSelectorPopover.find("li").length, 3, "there should be three available fields");
+ assert.strictEqual($fieldSelectorPopover.find("li").text().trim().replace(/\s+/g, ' '), "Bar Foo Product", "the available field should be correct");
+ $searchInput.val('xx').trigger('keyup');
+
+ assert.strictEqual($fieldSelectorPopover.find("li").length, 0, "there shouldn't be any element");
+ $searchInput.val('Pro').trigger('keyup');
+ assert.strictEqual($fieldSelectorPopover.find("li").length, 1, "there should only be one element");
+ assert.strictEqual($fieldSelectorPopover.find("li").text().trim().replace(/\s+/g, ' '), "Product", "the available field should be the Product");
+
+ fieldSelector.destroy();
+ });
});
});
});
diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml
index 4e9c32d5385..cc5c9b30a65 100644
--- a/addons/web/views/webclient_templates.xml
+++ b/addons/web/views/webclient_templates.xml
@@ -68,7 +68,6 @@
<link rel="stylesheet" type="text/less" href="/web/static/src/scss/fontawesome_overriden.scss"/>
</t>
- <script type="text/javascript" src="/web/static/lib/es5-shim/es5-shim.min.js"></script>
<script type="text/javascript" src="/web/static/lib/underscore/underscore.js"></script>
<script type="text/javascript" src="/web/static/lib/underscore.string/lib/underscore.string.js"></script>
<script type="text/javascript" src="/web/static/lib/moment/moment.js"></script>
@@ -274,6 +273,7 @@
<script type="text/javascript" src="/web/static/src/js/views/standalone_field_manager_mixin.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/view_registry.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/view_dialogs.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/views/view_utils.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/form/form_renderer.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/form/form_controller.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/form/form_view.js"></script>
@@ -511,6 +511,9 @@
<script type="text/javascript" src="/web/static/tests/fields/basic_fields_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/fields/field_utils_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/fields/relational_fields_tests.js"></script>
+ <script type="text/javascript" src="/web/static/tests/fields/relational_fields/field_many_to_many_tests.js"></script>
+ <script type="text/javascript" src="/web/static/tests/fields/relational_fields/field_many_to_one_tests.js"></script>
+ <script type="text/javascript" src="/web/static/tests/fields/relational_fields/field_one_to_many_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/fields/special_fields.js"></script>
<script type="text/javascript" src="/web/static/tests/fields/upgrade_fields_tests.js"></script>
diff --git a/addons/web_editor/models/ir_translation.py b/addons/web_editor/models/ir_translation.py
index 1afaebe9d39..91fb527f7b6 100644
--- a/addons/web_editor/models/ir_translation.py
+++ b/addons/web_editor/models/ir_translation.py
@@ -8,7 +8,7 @@ from odoo.tools.translate import encode, xml_translate, html_translate
def edit_translation_mapping(data):
- data = dict(data, model=data['name'].partition(',')[0])
+ data = dict(data, model=data['name'].partition(',')[0], value=data['value'] or data['src'])
return '<span data-oe-model="%(model)s" data-oe-translation-id="%(id)s" data-oe-translation-state="%(state)s">%(value)s</span>' % data
diff --git a/addons/web_editor/static/src/js/editor/snippets.editor.js b/addons/web_editor/static/src/js/editor/snippets.editor.js
index c5bc3b9bace..d5bb2c3b696 100644
--- a/addons/web_editor/static/src/js/editor/snippets.editor.js
+++ b/addons/web_editor/static/src/js/editor/snippets.editor.js
@@ -782,7 +782,8 @@ var SnippetsMenu = Widget.extend({
* @param {jQuery|false} $snippet
* The DOM element whose editor need to be enabled. Only disable the
* current one if false is given.
- * @returns {Deferred} (might be async when an editor must be created)
+ * @returns {Deferred<SnippetEditor>}
+ * (might be async when an editor must be created)
*/
_activateSnippet: function ($snippet) {
if ($snippet) {
@@ -790,25 +791,26 @@ var SnippetsMenu = Widget.extend({
$snippet = globalSelector.closest($snippet);
}
if (this.$activeSnippet && this.$activeSnippet[0] === $snippet[0]) {
- return $.when();
+ return $.when($snippet.data('snippet-editor'));
}
}
+ var editor = null;
if (this.$activeSnippet) {
- if (this.$activeSnippet.data('snippet-editor')) {
- this.$activeSnippet.data('snippet-editor').toggleFocus(false);
+ editor = this.$activeSnippet.data('snippet-editor');
+ if (editor) {
+ editor.toggleFocus(false);
}
this.$activeSnippet = false;
}
if ($snippet && $snippet.length) {
var self = this;
- return this._createSnippetEditor($snippet).then(function () {
+ return this._createSnippetEditor($snippet).then(function (editor) {
self.$activeSnippet = $snippet;
- if (self.$activeSnippet.data('snippet-editor')) {
- self.$activeSnippet.data('snippet-editor').toggleFocus(true);
- }
+ editor.toggleFocus(true);
+ return editor;
});
}
- return $.when();
+ return $.when(editor);
},
/**
* @private
diff --git a/addons/website/controllers/main.py b/addons/website/controllers/main.py
index 3705620a7c6..e68d208fb52 100644
--- a/addons/website/controllers/main.py
+++ b/addons/website/controllers/main.py
@@ -362,15 +362,17 @@ class Website(Home):
return [enable, disable]
@http.route(['/website/theme_customize'], type='json', auth="public", website=True)
- def theme_customize(self, enable, disable, get_bundle=False):
+ def theme_customize(self, enable=None, disable=None, get_bundle=False):
""" enable or Disable lists of ``xml_id`` of the inherit templates """
def set_active(xml_ids, active):
if xml_ids:
real_ids = self.get_view_ids(xml_ids)
request.env['ir.ui.view'].browse(real_ids).write({'active': active})
- set_active(disable, False)
- set_active(enable, True)
+ if disable:
+ set_active(disable, False)
+ if enable:
+ set_active(enable, True)
if get_bundle:
context = dict(request.context)
diff --git a/addons/website/models/ir_qweb.py b/addons/website/models/ir_qweb.py
index 32d221ea5ea..0a36e8fa8ee 100644
--- a/addons/website/models/ir_qweb.py
+++ b/addons/website/models/ir_qweb.py
@@ -12,11 +12,11 @@ re_background_image = re.compile(r"(background-image\s*:\s*url\(\s*['\"]?\s*)([^
class AssetsBundleMultiWebsite(AssetsBundle):
- def _get_asset_url_values(self, id, unique, extra, name, page, type):
+ def _get_asset_url_values(self, id, unique, extra, name, sep, type):
website_id = self.env.context.get('website_id')
website_id_path = website_id and ('%s/' % website_id) or ''
extra = website_id_path + extra
- res = super(AssetsBundleMultiWebsite, self)._get_asset_url_values(id, unique, extra, name, page, type)
+ res = super(AssetsBundleMultiWebsite, self)._get_asset_url_values(id, unique, extra, name, sep, type)
return res
diff --git a/addons/website/models/res_partner.py b/addons/website/models/res_partner.py
index 247804c387b..81ae2e73b92 100644
--- a/addons/website/models/res_partner.py
+++ b/addons/website/models/res_partner.py
@@ -22,7 +22,7 @@ class Partner(models.Model):
if not google_maps_api_key:
return False
params = {
- 'center': '%s, %s %s, %s' % (self.street or '', self.city or '', self.zip or '', self.country_id and self.country_id.name_get()[0][1] or ''),
+ 'center': '%s, %s %s, %s' % (self.street or '', self.city or '', self.zip or '', self.country_id and self.country_id.display_name or ''),
'size': "%sx%s" % (width, height),
'zoom': zoom,
'sensor': 'false',
@@ -33,7 +33,7 @@ class Partner(models.Model):
@api.multi
def google_map_link(self, zoom=10):
params = {
- 'q': '%s, %s %s, %s' % (self.street or '', self.city or '', self.zip or '', self.country_id and self.country_id.name_get()[0][1] or ''),
+ 'q': '%s, %s %s, %s' % (self.street or '', self.city or '', self.zip or '', self.country_id and self.country_id.display_name or ''),
'z': zoom,
}
return urlplus('https://maps.google.com/maps', params)
diff --git a/addons/website/models/website.py b/addons/website/models/website.py
index c592ad32b57..66b73c95b37 100644
--- a/addons/website/models/website.py
+++ b/addons/website/models/website.py
@@ -993,7 +993,7 @@ class Page(models.Model):
# Create redirect if needed
if data['create_redirect']:
self.env['website.redirect'].create({
- 'type': data['redirect_type'],
+ 'redirect_type': data['redirect_type'],
'url_from': original_url,
'url_to': url,
'website_id': website.id,
@@ -1215,7 +1215,7 @@ class WebsiteRedirect(models.Model):
_order = "sequence, id"
_rec_name = 'url_from'
- type = fields.Selection([('301', 'Moved permanently (301)'), ('302', 'Moved temporarily (302)')], string='Redirection Type', required=True)
+ redirect_type = fields.Selection([('301', 'Moved permanently (301)'), ('302', 'Moved temporarily (302)')], string='Redirection Type', required=True, oldname='type')
url_from = fields.Char('Redirect From', required=True)
url_to = fields.Char('Redirect To', required=True)
website_id = fields.Many2one('website', 'Website')
diff --git a/addons/website/static/src/js/widgets/theme.js b/addons/website/static/src/js/widgets/theme.js
index 420f90da04d..7646f4002a0 100644
--- a/addons/website/static/src/js/widgets/theme.js
+++ b/addons/website/static/src/js/widgets/theme.js
@@ -154,72 +154,45 @@ var ThemeCustomizeDialog = Dialog.extend({
switch (item.tagName) {
case 'OPT':
- var colorPalette = $item.data('colorPalette') === 'user';
- var icon = $item.data('icon');
+ var widgetName = $item.data('widget');
// Build the options template
- var $multiChoiceLabel = $(core.qweb.render('website.theme_customize_modal_option', {
+ var $option = $(core.qweb.render('website.theme_customize_modal_option', {
name: optionsName,
id: $item.attr('id') || _.uniqueId('o_theme_customize_input_id_'),
string: $item.attr('string'),
- icon: icon,
- color: $item.data('color'),
+ icon: $item.data('icon'),
font: $item.data('font'),
- colorPalette: colorPalette,
-
xmlid: $item.data('xmlid'),
enable: $item.data('enable'),
disable: $item.data('disable'),
reload: $item.data('reload'),
+
+ widget: widgetName,
}));
- $multiChoiceLabel.find('.o_theme_customize_color[data-color="primary"]').addClass('d-none').removeClass('d-flex');
- $multiChoiceLabel.find('.o_theme_customize_color[data-color="secondary"]').addClass('d-none').removeClass('d-flex');
+ if (widgetName) {
+ var $widget = $(core.qweb.render('website.theme_customize_' + widgetName));
+ $option.append($widget);
+ }
if ($container.hasClass('form-row')) {
- $col = $('<div/>', {class: (icon ? 'col-4' : (colorPalette ? 'col-12' : 'col-6'))});
- $col.append($multiChoiceLabel);
+ $col = $('<div/>', {
+ class: _.str.sprintf('col-%s', $item.data('col') || 6),
+ });
+ $col.append($option);
$container.append($col);
} else {
- $container.append($multiChoiceLabel);
- }
- break;
-
- case 'MORE':
- var collapseID = _.uniqueId('collapse-');
-
- $col = $('<div/>', {
- class: 'col-12',
- }).appendTo($container);
-
- var string = $item.attr('string');
- if (string) {
- var $button = $('<button/>', {
- 'type': 'button',
- class: 'btn btn-primary d-block mx-auto mt-3 collapsed',
- 'data-toggle': 'collapse',
- 'data-target': "#" + collapseID,
- text: string,
- });
- $col.append($button);
+ $container.append($option);
}
-
- var $collapse = $('<div/>',{
- id: collapseID,
- class: 'collapse form-row justify-content-between mt-3',
- 'data-depends': $item.data('depends'),
- });
- $col.append($collapse);
-
- _processItems($item.children(), $collapse);
break;
case 'LIST':
var $listContainer = $('<div/>', {class: 'py-1 px-2 o_theme_customize_option_list'});
$col = $('<div/>', {
- class: 'col-6 mt-2',
+ class: _.str.sprintf('col-%s mt-2', $item.data('col') || 6),
'data-depends': $item.data('depends'),
}).append($('<h6/>', {text: $item.attr('string')}), $listContainer);
$container.append($col);
@@ -353,21 +326,13 @@ var ThemeCustomizeDialog = Dialog.extend({
$set.prop('checked', checked).closest('label').toggleClass('checked', checked);
});
- // Mark the collapsed section as visible if their dependencies are met
- var $collapsedElements = this.$('[data-depends]');
- _.each($collapsedElements, function (collapsed) {
- var $collapsed = $(collapsed);
- var enabled = false;
- var nbDependencies = $collapsed.data('depends') ? $collapsed.data('depends').split(',').length : 0;
- if (self._getInputs($collapsed.data('depends')).filter(':checked').length === nbDependencies) {
- enabled = true;
- }
-
- if ($collapsed.is('.collapse')) {
- $collapsed.collapse(enabled ? 'show' : 'hide');
- } else {
- $collapsed.toggleClass('d-none', !enabled);
- }
+ // Make the hidden sections visible if their dependencies are met
+ _.each(this.$('[data-depends]'), function (hidden) {
+ var $hidden = $(hidden);
+ var depends = $hidden.data('depends');
+ var nbDependencies = depends ? depends.split(',').length : 0;
+ var enabled = self._getInputs(depends).filter(':checked').length === nbDependencies;
+ $hidden.toggleClass('d-none', !enabled);
});
},
/**
@@ -377,8 +342,8 @@ var ThemeCustomizeDialog = Dialog.extend({
if (reload || config.debug === 'assets') {
window.location.href = $.param.querystring('/website/theme_customize_reload', {
href: window.location.href,
- enable: enable.join(','),
- disable: disable.join(','),
+ enable: (enable || []).join(','),
+ disable: (disable || []).join(','),
tab: this.$('.nav-link.active').parent().index(),
});
return $.Deferred();
@@ -538,7 +503,7 @@ var ThemeCustomizeDialog = Dialog.extend({
},
});
}).then(function () {
- self.$('#' + $color.closest('.o_theme_customize_color_previews').data('depends')).click();
+ return self._updateStyle();
});
});
colorpicker.open();
diff --git a/addons/website/static/src/scss/website.edit_mode.scss b/addons/website/static/src/scss/website.edit_mode.scss
index 681156835ab..de6f5d4c1a4 100644
--- a/addons/website/static/src/scss/website.edit_mode.scss
+++ b/addons/website/static/src/scss/website.edit_mode.scss
@@ -86,10 +86,6 @@
line-height: 1;
cursor: pointer;
- &.o_theme_customize_option_palette_label {
- display: none !important;
- }
-
&.checked {
border-color: $o-brand-odoo;
color: $o-brand-odoo;
diff --git a/addons/website/static/src/xml/website.editor.xml b/addons/website/static/src/xml/website.editor.xml
index b3f823314f9..5018d28d1d9 100644
--- a/addons/website/static/src/xml/website.editor.xml
+++ b/addons/website/static/src/xml/website.editor.xml
@@ -22,45 +22,47 @@
<t t-name="website.theme_customize_modal_option">
<div t-attf-class="o_theme_customize_option my-1 flex-grow-0 #{font ? 'o_theme_customize_option_font_' + font : ''}">
<img t-if="icon" t-att-src="icon"/>
- <label t-attf-class="d-flex align-items-center justify-content-center text-center #{color ? 'bg-' + color : ''} #{colorPalette ? 'o_theme_customize_option_palette_label' : ''}">
+ <label t-attf-class="#{widget ? 'd-none' : 'd-flex'} align-items-center justify-content-center text-center">
<input t-att-id="id" t-att-name="name" type="radio" class="o_theme_customize_option_input" t-att-data-xmlid="xmlid" t-att-data-enable="enable" t-att-data-disable="disable" t-att-data-reload="reload"/>
<span><t t-esc="string"/></span>
</label>
- <div t-if="colorPalette" class="o_theme_customize_color_previews" t-att-data-depends="id">
- <div>
- <h6>Main colors</h6>
- <t t-foreach="{'primary': _t('Primary'), 'secondary': _t('Secondary'), 'alpha': _t('Primary'), 'beta': _t('Secondary'), 'gamma': _t('Extra Color'), 'delta': _t('Extra Color'), 'epsilon': _t('Extra Color')}" t-as="color">
- <t t-call="website.theme_customize_color_palette_button">
- <t t-set="color_type" t-value="'theme'"/>
- </t>
- </t>
- </div>
- <div>
- <h6>Background colors</h6>
- <t t-foreach="{'body': _t('Body'), 'menu': _t('Menus'), 'footer': _t('Footer')}" t-as="color">
- <t t-call="website.theme_customize_color_palette_button"/>
- </t>
- </div>
- <div>
- <h6>Text colors</h6>
- <t t-foreach="{'text': _t('Text'), 'h1': _t('Headings 1'), 'h2': _t('Headings 2'), 'h3': _t('Headings 3'), 'h4': _t('Headings 4'), 'h5': _t('Headings 5'), 'h6': _t('Headings 6')}" t-as="color">
- <t t-call="website.theme_customize_color_palette_button"/>
+ </div>
+ </t>
+ <t t-name="website.theme_customize_color_palette">
+ <div class="o_theme_customize_color_previews">
+ <div>
+ <h6>Main colors</h6>
+ <t t-foreach="{'primary': _t('Primary'), 'secondary': _t('Secondary'), 'alpha': _t('Primary'), 'beta': _t('Secondary'), 'gamma': _t('Extra Color'), 'delta': _t('Extra Color'), 'epsilon': _t('Extra Color')}" t-as="color">
+ <t t-call="website.theme_customize_color_palette_button">
+ <t t-set="color_type" t-value="'theme'"/>
</t>
- </div>
- <div>
- <h6>State colors</h6>
- <t t-foreach="{'success': _t('Success'), 'info': _t('Info'), 'warning': _t('Warning'), 'danger': _t('Error')}" t-as="color">
- <t t-call="website.theme_customize_color_palette_button">
- <t t-set="color_type" t-value="'theme'"/>
- </t>
+ </t>
+ </div>
+ <div>
+ <h6>Background colors</h6>
+ <t t-foreach="{'body': _t('Body'), 'menu': _t('Menus'), 'footer': _t('Footer')}" t-as="color">
+ <t t-call="website.theme_customize_color_palette_button"/>
+ </t>
+ </div>
+ <div>
+ <h6>Text colors</h6>
+ <t t-foreach="{'text': _t('Text'), 'h1': _t('Headings 1'), 'h2': _t('Headings 2'), 'h3': _t('Headings 3'), 'h4': _t('Headings 4'), 'h5': _t('Headings 5'), 'h6': _t('Headings 6')}" t-as="color">
+ <t t-call="website.theme_customize_color_palette_button"/>
+ </t>
+ </div>
+ <div>
+ <h6>State colors</h6>
+ <t t-foreach="{'success': _t('Success'), 'info': _t('Info'), 'warning': _t('Warning'), 'danger': _t('Error')}" t-as="color">
+ <t t-call="website.theme_customize_color_palette_button">
+ <t t-set="color_type" t-value="'theme'"/>
</t>
- </div>
+ </t>
</div>
</div>
</t>
<t t-name="website.theme_customize_color_palette_button">
<button type="button"
- t-attf-class="btn btn-sm btn-secondary d-flex align-items-center w-100 mt-2 justify-content-between o_theme_customize_color o_theme_customize_color_#{color}"
+ t-attf-class="btn btn-secondary #{color == 'primary' or color == 'secondary' ? 'd-none' : 'd-flex'} align-items-center w-100 mt-2 justify-content-between o_theme_customize_color o_theme_customize_color_#{color}"
t-att-data-color="color"
t-att-data-color-type="color_type">
<span class="o_color_name"><t t-esc="color_value"/></span>
diff --git a/addons/website/views/website_templates.xml b/addons/website/views/website_templates.xml
index 614c9f1971f..81cbff1d0cd 100644
--- a/addons/website/views/website_templates.xml
+++ b/addons/website/views/website_templates.xml
@@ -443,46 +443,49 @@
<opt>: Declares a new toggle option
-> id (optional) = an ID to associate to the option
+
-> string (optional) = option's text
-> data-icon (optional) = an image URL to set as option's background
- -> data-xmlid (optional) = template(s) to enable if the input is checked (list of comma-separated xml ids)
- -> data-enable (optional) = enable other options (list of comma-separated option ids)
- -> data-disable (optional) = disable other options (list of comma-separated option ids)
- -> data-reload (optional) = force the reloading of the page if the url matches the value
- Otherwise, only the main assets are reloaded
- -> data-font (optional) = indicates which fonts to use to style the option and adds
- default sample text if no 'string' is specified
- (see $o-theme-fonts in scss)
- -> data-color-palette (optional) = indicates which color palette to use to style the option
- (see $o-theme-color-palettes in scss)
-
- <more>: Declares a subset of options which is hidden by default (with a button to show it)
- -> string = button's text
+ -> data-font (optional) = indicates which fonts to use to style the
+ option and adds default sample text if no 'string' is specified
+ (see $o-theme-fonts in scss)
+ -> data-col (optional, default 6) = bootstrap column size for the option
+ (ignored for options in a <list/>)
+
+ -> data-xmlid (optional) = template(s) to enable if the input is
+ checked (list of comma-separated xml ids)
+ -> data-enable (optional) = enable other options (list of
+ comma-separated option ids)
+ -> data-disable (optional) = disable other options (list of
+ comma-separated option ids)
+ -> data-reload (optional) = force the reloading of the page if the url
+ matches the value. Otherwise, only the main assets are reloaded
+
+ -> data-widget (optional) = indicates which widget to use (available
+ widgets are: color_palette)
<list>: Declares a subset of options showed as a list
-> string = list's title
+ -> data-col (optional, default 6) = bootstrap column size for the list
-->
<template id="theme_customize">
<div>
<!-- Color options -->
<content id="theme_customize_content_colors" string="Colors" title="Choose the theme colors">
- <opt data-xmlid="" data-color-palette="user" string="Choose your colors"/>
+ <opt data-widget="color_palette" data-col="12"/>
</content>
<!-- Layout Options -->
<content id="theme_customize_content_layout" string="Layout" title="Choose your layout">
- <!-- In a <more/> so that it is on a new line if themes add options before -->
- <more data-depends="">
- <list string="Body">
- <opt string="Full" data-xmlid="" data-icon="/website/static/src/img/options/layout-full.png"/>
- <opt id="option_layout_boxed" string="Boxed" data-xmlid="website.option_layout_boxed_variables" data-icon="/website/static/src/img/options/layout-boxed.png"/>
- </list>
- <list string="Background">
- <opt id="option_no_background" string="None" data-xmlid=""/>
- <opt string="Choose an image" data-xmlid="website.option_custom_body_image"/>
- <opt string="Choose a pattern" data-xmlid="website.option_custom_body_image, website.option_custom_body_pattern"/>
- </list>
- </more>
+ <list string="Body">
+ <opt string="Full" data-xmlid="" data-icon="/website/static/src/img/options/layout-full.png"/>
+ <opt id="option_layout_boxed" string="Boxed" data-xmlid="website.option_layout_boxed_variables" data-icon="/website/static/src/img/options/layout-boxed.png"/>
+ </list>
+ <list string="Background">
+ <opt id="option_no_background" string="None" data-xmlid=""/>
+ <opt string="Choose an image" data-xmlid="website.option_custom_body_image"/>
+ <opt string="Choose a pattern" data-xmlid="website.option_custom_body_image, website.option_custom_body_pattern"/>
+ </list>
</content>
<!-- Font options -->
diff --git a/addons/website/views/website_views.xml b/addons/website/views/website_views.xml
index 37b419a9b77..5f94b183211 100644
--- a/addons/website/views/website_views.xml
+++ b/addons/website/views/website_views.xml
@@ -70,7 +70,7 @@
<field name="url_to"/>
<group string="Group By">
- <filter string="Redirection Type" name="group_by_type" domain="[]" context="{'group_by': 'type'}"/>
+ <filter string="Redirection Type" name="group_by_type" domain="[]" context="{'group_by': 'redirect_type'}"/>
</group>
</search>
</field>
@@ -94,7 +94,7 @@
<sheet>
<group>
<group>
- <field name="type"/>
+ <field name="redirect_type"/>
<field name="url_from"/>
<field name="url_to"/>
</group>
@@ -113,7 +113,7 @@
<field name="model">website.redirect</field>
<field name="arch" type="xml">
<tree string="Website Redirects" editable="bottom">
- <field name="type"/>
+ <field name="redirect_type"/>
<field name="url_from"/>
<field name="url_to"/>
<field name="website_id" options="{'no_create': True}" groups="website.group_multi_website"/>
diff --git a/addons/website_crm_partner_assign/views/res_partner_views.xml b/addons/website_crm_partner_assign/views/res_partner_views.xml
index 1da1f3ef402..4ecb010335e 100644
--- a/addons/website_crm_partner_assign/views/res_partner_views.xml
+++ b/addons/website_crm_partner_assign/views/res_partner_views.xml
@@ -10,10 +10,12 @@
<field name="model">res.partner.activation</field>
<field name="arch" type="xml">
<form string="Activation">
- <group col="4">
- <field name="name" />
- <field name="sequence" />
- </group>
+ <sheet>
+ <group col="4">
+ <field name="name" />
+ <field name="sequence" />
+ </group>
+ </sheet>
</form>
</field>
</record>
diff --git a/addons/website_event/models/event.py b/addons/website_event/models/event.py
index 460f1bd818c..3b02869cf2e 100644
--- a/addons/website_event/models/event.py
+++ b/addons/website_event/models/event.py
@@ -118,9 +118,9 @@ class Event(models.Model):
def _track_subtype(self, init_values):
self.ensure_one()
if 'is_published' in init_values and self.is_published:
- return 'website_event.mt_event_published'
+ return self.env.ref('website_event.mt_event_published')
elif 'is_published' in init_values and not self.is_published:
- return 'website_event.mt_event_unpublished'
+ return self.env.ref('website_event.mt_event_unpublished')
return super(Event, self)._track_subtype(init_values)
@api.multi
diff --git a/addons/website_event_track/models/event_track.py b/addons/website_event_track/models/event_track.py
index 2f35e86bb0e..1c4758d50af 100644
--- a/addons/website_event_track/models/event_track.py
+++ b/addons/website_event_track/models/event_track.py
@@ -151,9 +151,9 @@ class Track(models.Model):
def _track_subtype(self, init_values):
self.ensure_one()
if 'kanban_state' in init_values and self.kanban_state == 'blocked':
- return 'website_event_track.mt_track_blocked'
+ return self.env.ref('website_event_track.mt_track_blocked')
elif 'kanban_state' in init_values and self.kanban_state == 'done':
- return 'website_event_track.mt_track_ready'
+ return self.env.ref('website_event_track.mt_track_ready')
return super(Track, self)._track_subtype(init_values)
@api.multi
diff --git a/addons/website_google_map/controllers/main.py b/addons/website_google_map/controllers/main.py
index 092fab26a03..169eb46f7c5 100644
--- a/addons/website_google_map/controllers/main.py
+++ b/addons/website_google_map/controllers/main.py
@@ -43,7 +43,7 @@ class GoogleMap(http.Controller):
partner_data["partners"].append({
'id': partner.id,
'name': escape(partner.name),
- 'address': escape('\n'.join(partner.name_get()[0][1].split('\n')[1:])),
+ 'address': escape('\n'.join(partner.display_name.split('\n')[1:])),
'latitude': escape(str(partner.partner_latitude)),
'longitude': escape(str(partner.partner_longitude)),
})
diff --git a/addons/website_sale/data/demo.xml b/addons/website_sale/data/demo.xml
index 97217d3d11a..d33a5bfa86a 100644
--- a/addons/website_sale/data/demo.xml
+++ b/addons/website_sale/data/demo.xml
@@ -653,6 +653,7 @@
<field name="description_sale">Warranty, issued to the purchaser of an article by its manufacturer, promising to repair or replace it if necessary within a specified period of time.</field>
<field name="attribute_value_ids" eval="[(6,0,[ref('product.product_attribute_value_5')])]"/>
<field name="categ_id" ref="product.product_category_3"/>
+ <field name="invoice_policy">delivery</field>
</record>
<record id="product_template_attribute_line_1" model="product.template.attribute.line">
<field name="product_tmpl_id" ref="product_product_1_product_template"/>
diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml
index dcc5c468c61..d5e6abb05f8 100644
--- a/addons/website_sale/views/templates.xml
+++ b/addons/website_sale/views/templates.xml
@@ -630,7 +630,7 @@
<div t-foreach="product.product_variant_ids" t-as="variant_id" class="custom-control custom-radio">
<input type="radio" name="product_id" class="custom-control-input js_product_change" t-att-checked="'checked' if variant_id_index == 0 else None" t-attf-id="radio_variant_#{variant_id.id}" t-att-value="variant_id.id" t-att-data-lst_price="compute_currency(variant_id.lst_price)" t-att-data-price="variant_id.price"/>
<label t-attf-for="radio_variant_#{variant_id.id}" label-default="label-default" class="custom-control-label">
- <span t-esc="variant_id.name_get()[0][1]"/>
+ <span t-esc="variant_id.display_name"/>
<t t-set="diff_price" t-value="variant_id.price - variant_id.product_tmpl_id.price"/>
<span class="badge badge-pill" t-if="diff_price != 0">
<t t-esc="diff_price > 0 and '+' or '-'"/><span t-esc="abs(diff_price)" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
diff --git a/debian/control b/debian/control
index 7600e8bf580..2cba3381590 100644
--- a/debian/control
+++ b/debian/control
@@ -35,11 +35,13 @@ Depends:
python3-pydot,
python3-pyparsing,
python3-pypdf2,
+ python3-qrcode,
python3-reportlab,
python3-requests,
python3-suds,
python3-tz,
python3-vatnumber,
+ python3-vobject,
python3-werkzeug,
python3-xlsxwriter,
Conflicts: tinyerp-server, openerp-server, openerp-web, openerp
@@ -48,8 +50,6 @@ Recommends:
${python3:Recommends},
postgresql,
python3-pyldap,
- python3-qrcode,
- python3-vobject,
Description: Open Source Apps To Grow Your Business
Odoo, formerly known as OpenERP, is a suite of open-source business apps
written in Python and released under the LGPLv3 license. This suite of
diff --git a/debian/py3dist-overrides b/debian/py3dist-overrides
deleted file mode 100644
index 3264059ab84..00000000000
--- a/debian/py3dist-overrides
+++ /dev/null
@@ -1,3 +0,0 @@
-pyldap
-vobject
-qrcode \ No newline at end of file
diff --git a/doc/cla/individual/brunojppb.md b/doc/cla/individual/brunojppb.md
new file mode 100644
index 00000000000..2f305dd8246
--- /dev/null
+++ b/doc/cla/individual/brunojppb.md
@@ -0,0 +1,11 @@
+Austria, 2018-10-02
+
+I hereby agree to the terms of the Odoo Individual Contributor License
+Agreement v1.0.
+
+I declare that I am authorized and able to make this agreement and sign this
+declaration.
+
+Signed,
+
+Bruno Paulino bruno@woombikes.com https://github.com/brunojppb
diff --git a/doc/reference/javascript_reference.rst b/doc/reference/javascript_reference.rst
index 058c49340ed..ec125aa9b41 100644
--- a/doc/reference/javascript_reference.rst
+++ b/doc/reference/javascript_reference.rst
@@ -129,9 +129,8 @@ Here is what happens when a template is rendered by the server with these direct
be replaced by a list of script tags pointing to the js files
- if we are not in *debug=assets* mode,
- - the css files will be concatenated and minified, then splits into files
- with no more than 4096 rules (to get around an old limitation of IE9). Then,
- we generate as many stylesheet tags as necessary
+ - the css files will be concatenated and minified, then a stylesheet tag is
+ generated
- the js files are concatenated and minified, then a script tag is generated
Note that the assets files are cached, so in theory, a browser should only load
diff --git a/doc/reference/mixins.rst b/doc/reference/mixins.rst
index d8c976ddaaa..af06849b435 100644
--- a/doc/reference/mixins.rst
+++ b/doc/reference/mixins.rst
@@ -331,7 +331,7 @@ can override the ``_track_subtype()`` function:
# in cache
self.ensure_one()
if 'state' in init_values and self.state == 'confirmed':
- return 'my_module.mt_state_change' # Full external id
+ return self.env.ref('my_module.mt_state_change')
return super(BusinessTrip, self)._track_subtype(init_values)
diff --git a/doc/reference/orm.rst b/doc/reference/orm.rst
index d2c8f812f74..05254797ee8 100644
--- a/doc/reference/orm.rst
+++ b/doc/reference/orm.rst
@@ -1033,7 +1033,9 @@ at runtime) but less power: using the :attr:`~odoo.models.Model._inherits`
a model *delegates* the lookup of any field not found on the current model
to "children" models. The delegation is performed via
:class:`~odoo.fields.Reference` fields automatically set up on the parent
-model:
+model. The main difference is in the meaning. When using Delegation, the model
+**has one** instead of **is one**, turning the relationship in a composition
+instead of inheritance:
.. literalinclude:: ../../odoo/addons/test_documentation_examples/delegation.py
:language: python
diff --git a/odoo/addons/base/data/res_country_data.xml b/odoo/addons/base/data/res_country_data.xml
index d924f2ee042..7bf65eea7d9 100644
--- a/odoo/addons/base/data/res_country_data.xml
+++ b/odoo/addons/base/data/res_country_data.xml
@@ -349,7 +349,6 @@
<field name="name">Colombia</field>
<field name="code">co</field>
<field file="base/static/img/country_flags/co.png" name="image" type="base64" />
- <field eval="'%(street)s\n%(street2)s\n%(city)s %(state_name)s %(zip)s\n%(country_name)s'" name="address_format" />
<field name="currency_id" ref="COP" />
<field eval="57" name="phone_code" />
<field name="address_format" eval="'%(street)s\n%(street2)s\n%(city)s %(state_name)s %(zip)s\n%(country_name)s'" />
diff --git a/odoo/addons/base/data/res_users_data.xml b/odoo/addons/base/data/res_users_data.xml
index b28572009ce..f274489fb1d 100644
--- a/odoo/addons/base/data/res_users_data.xml
+++ b/odoo/addons/base/data/res_users_data.xml
@@ -6,6 +6,7 @@
<field name="partner_id" ref="base.partner_root"/>
<field name="company_id" ref="main_company"/>
<field name="company_ids" eval="[(4, ref('main_company'))]"/>
+ <field name="email">root@example.com</field>
<field name="signature"><![CDATA[<span>-- <br/>
System</span>]]></field>
</record>
diff --git a/odoo/addons/base/models/assetsbundle.py b/odoo/addons/base/models/assetsbundle.py
index 43602bad89c..f3754859b4b 100644
--- a/odoo/addons/base/models/assetsbundle.py
+++ b/odoo/addons/base/models/assetsbundle.py
@@ -27,8 +27,6 @@ from odoo.tools import func, misc
import logging
_logger = logging.getLogger(__name__)
-MAX_CSS_RULES = 4095
-
class CompileError(RuntimeError): pass
def rjsmin(script):
@@ -92,7 +90,6 @@ class AssetsBundle(object):
def __init__(self, name, files, remains=None, env=None):
self.name = name
self.env = request.env if env is None else env
- self.max_css_rules = self.env.context.get('max_css_rules', MAX_CSS_RULES)
self.javascripts = []
self.stylesheets = []
self.css_errors = []
@@ -199,31 +196,27 @@ class AssetsBundle(object):
return hashlib.sha1(check.encode('utf-8')).hexdigest()
def _get_asset_template_url(self):
- return "/web/content/{id}-{unique}/{extra}{name}{page}{type}" # name contains inc
+ return "/web/content/{id}-{unique}/{extra}{name}{sep}{type}"
- def _get_asset_url_values(self, id, unique, extra, name, page, type): # extra can contain direction or/and website
+ def _get_asset_url_values(self, id, unique, extra, name, sep, type): # extra can contain direction or/and website
return {
'id': id,
'unique': unique,
'extra': extra,
'name': name,
- 'page': page,
+ 'sep': sep,
'type': type,
}
- def get_asset_url(self, id='%', unique='%', extra='', name='%', page='%', type='%'):
+ def get_asset_url(self, id='%', unique='%', extra='', name='%', sep="%", type='%'):
return self._get_asset_template_url().format(
- **self._get_asset_url_values(id=id, unique=unique, extra=extra, name=name, page=page, type=type)
+ **self._get_asset_url_values(id=id, unique=unique, extra=extra, name=name, sep=sep, type=type)
)
def clean_attachments(self, type):
""" Takes care of deleting any outdated ir.attachment records associated to a bundle before
saving a fresh one.
- When `type` is css we need to check that we are deleting a different version (and not *any*
- version) because css may be paginated and, therefore, may produce multiple attachments for
- the same bundle's version.
-
When `type` is js we need to check that we are deleting a different version (and not *any*
version) because, as one of the creates in `save_attachment` can trigger a rollback, the
call to `clean_attachments ` is made at the end of the method in order to avoid the rollback
@@ -259,7 +252,7 @@ class AssetsBundle(object):
unique=unique,
extra='%s' % ('rtl/' if type == 'css' and self.user_direction == 'rtl' else ''),
name=self.name,
- page='.%' if type == 'css' else '',
+ sep='',
type='.%s' % type
)
self.env.cr.execute("""
@@ -273,7 +266,7 @@ class AssetsBundle(object):
attachment_ids = [r[0] for r in self.env.cr.fetchall()]
return self.env['ir.attachment'].sudo().browse(attachment_ids)
- def save_attachment(self, type, content, inc=None):
+ def save_attachment(self, type, content):
assert type in ('js', 'css')
ira = self.env['ir.attachment']
@@ -281,11 +274,7 @@ class AssetsBundle(object):
# 1 for ltr and 1 for rtl, this will help during cleaning of assets bundle
# and allow to only clear the current direction bundle
# (this applies to css bundles only)
- fname = '%s%s.%s' % (
- self.name,
- ('' if inc is None else '.%s' % inc),
- type
- )
+ fname = '%s.%s' % (self.name, type)
mimetype = 'application/javascript' if type == 'js' else 'text/css'
values = {
'name': "/web/content/%s" % type,
@@ -304,8 +293,8 @@ class AssetsBundle(object):
unique=self.version,
extra='%s' % ('rtl/' if type == 'css' and self.user_direction == 'rtl' else ''),
name=fname,
- page='', # included in fname
- type='' # included in fname
+ sep='', # included in fname
+ type=''
)
values = {
'name': url,
@@ -341,23 +330,7 @@ class AssetsBundle(object):
matches.append(css)
css = u'\n'.join(matches)
- # split for browser max file size and browser max expression
- re_rules = '([^{]+\{(?:[^{}]|\{[^{}]*\})*\})'
- re_selectors = '()(?:\s*@media\s*[^{]*\{)?(?:\s*(?:[^,{]*(?:,|\{(?:[^}]*\}))))'
- page = []
- pages = [page]
- page_selectors = 0
- for rule in re.findall(re_rules, css):
- selectors = len(re.findall(re_selectors, rule))
- if page_selectors + selectors <= self.max_css_rules:
- page_selectors += selectors
- page.append(rule)
- else:
- pages.append([rule])
- page = pages[-1]
- page_selectors = selectors
- for idx, page in enumerate(pages):
- self.save_attachment("css", ' '.join(page), inc=idx)
+ self.save_attachment("css", css)
attachments = self.get_attachments('css')
return attachments
diff --git a/odoo/addons/base/models/ir_actions.py b/odoo/addons/base/models/ir_actions.py
index 428fabd6dcf..634e5f60ca8 100644
--- a/odoo/addons/base/models/ir_actions.py
+++ b/odoo/addons/base/models/ir_actions.py
@@ -601,11 +601,11 @@ class IrServerObjectLines(models.Model):
"When Formula type is selected, this field may be a Python expression "
" that can use the same values as for the code field on the server action.\n"
"If Value type is selected, the value will be used directly without evaluation.")
- type = fields.Selection([
+ evaluation_type = fields.Selection([
('value', 'Value'),
('reference', 'Reference'),
('equation', 'Python expression')
- ], 'Evaluation Type', default='value', required=True, change_default=True)
+ ], 'Evaluation Type', default='value', required=True, change_default=True, oldname='type')
resource_ref = fields.Reference(
string='Record', selection='_selection_target_model',
compute='_compute_resource_ref', inverse='_set_resource_ref')
@@ -615,10 +615,10 @@ class IrServerObjectLines(models.Model):
models = self.env['ir.model'].search([])
return [(model.model, model.name) for model in models]
- @api.depends('col1.relation', 'value', 'type')
+ @api.depends('col1.relation', 'value', 'evaluation_type')
def _compute_resource_ref(self):
for line in self:
- if line.type in ['reference', 'value'] and line.col1 and line.col1.relation:
+ if line.evaluation_type in ['reference', 'value'] and line.col1 and line.col1.relation:
value = line.value or ''
try:
value = int(value)
@@ -634,7 +634,7 @@ class IrServerObjectLines(models.Model):
@api.onchange('resource_ref')
def _set_resource_ref(self):
- for line in self.filtered(lambda line: line.type == 'reference'):
+ for line in self.filtered(lambda line: line.evaluation_type == 'reference'):
if line.resource_ref:
line.value = str(line.resource_ref.id)
@@ -643,7 +643,7 @@ class IrServerObjectLines(models.Model):
result = dict.fromkeys(self.ids, False)
for line in self:
expr = line.value
- if line.type == 'equation':
+ if line.evaluation_type == 'equation':
expr = safe_eval(line.value, eval_context)
elif line.col1.ttype in ['many2one', 'integer']:
try:
diff --git a/odoo/addons/base/models/ir_actions_report.py b/odoo/addons/base/models/ir_actions_report.py
index b89807d23f9..797eeb56113 100644
--- a/odoo/addons/base/models/ir_actions_report.py
+++ b/odoo/addons/base/models/ir_actions_report.py
@@ -581,19 +581,22 @@ class IrActionsReport(models.Model):
if len(streams) == 1:
result = streams[0].getvalue()
else:
- writer = PdfFileWriter()
- for stream in streams:
- reader = PdfFileReader(stream)
- writer.appendPagesFromReader(reader)
- result_stream = io.BytesIO()
- streams.append(result_stream)
- writer.write(result_stream)
- result = result_stream.getvalue()
+ result = self._merge_pdfs(streams)
# We have to close the streams after PdfFileWriter's call to write()
close_streams(streams)
return result
+ def _merge_pdfs(self, streams):
+ writer = PdfFileWriter()
+ for stream in streams:
+ reader = PdfFileReader(stream)
+ writer.appendPagesFromReader(reader)
+ result_stream = io.BytesIO()
+ streams.append(result_stream)
+ writer.write(result_stream)
+ return result_stream.getvalue()
+
@api.multi
def render_qweb_pdf(self, res_ids=None, data=None):
if not data:
diff --git a/odoo/addons/base/models/ir_mail_server.py b/odoo/addons/base/models/ir_mail_server.py
index 84b44c19aaf..3bc39f4cb54 100644
--- a/odoo/addons/base/models/ir_mail_server.py
+++ b/odoo/addons/base/models/ir_mail_server.py
@@ -129,6 +129,7 @@ class IrMailServer(models.Model):
"""Represents an SMTP server, able to send outgoing emails, with SSL and TLS capabilities."""
_name = "ir.mail_server"
_description = 'Mail Server'
+ _order = 'sequence'
NO_VALID_RECIPIENT = ("At least one valid recipient address should be "
"specified for outgoing emails (To/Cc/Bcc)")
diff --git a/odoo/addons/base/models/ir_model.py b/odoo/addons/base/models/ir_model.py
index 76524a10814..afdae0d03d3 100644
--- a/odoo/addons/base/models/ir_model.py
+++ b/odoo/addons/base/models/ir_model.py
@@ -1118,6 +1118,7 @@ class IrModelRelation(models.Model):
class IrModelAccess(models.Model):
_name = 'ir.model.access'
_description = 'Model Access'
+ _order = 'model_id,group_id,name,id'
name = fields.Char(required=True, index=True)
active = fields.Boolean(default=True, help='If you uncheck the active field, it will disable the ACL without deleting it (if you delete a native ACL, it will be re-created when you reload the module).')
diff --git a/odoo/addons/base/models/ir_qweb_fields.py b/odoo/addons/base/models/ir_qweb_fields.py
index d0df08603e1..023a9b6ef07 100644
--- a/odoo/addons/base/models/ir_qweb_fields.py
+++ b/odoo/addons/base/models/ir_qweb_fields.py
@@ -643,7 +643,7 @@ class Contact(models.AbstractModel):
opf = options and options.get('fields') or ["name", "address", "phone", "mobile", "email"]
opsep = options and options.get('separator') or "\n"
value = value.sudo().with_context(show_address=True)
- name_get = value.name_get()[0][1]
+ name_get = value.display_name
val = {
'name': name_get.split("\n")[0],
diff --git a/odoo/addons/base/models/ir_rule.py b/odoo/addons/base/models/ir_rule.py
index 90869ef45ac..a011272d855 100644
--- a/odoo/addons/base/models/ir_rule.py
+++ b/odoo/addons/base/models/ir_rule.py
@@ -13,7 +13,7 @@ from odoo.tools.safe_eval import safe_eval
class IrRule(models.Model):
_name = 'ir.rule'
_description = 'Record Rule'
- _order = 'model_id DESC'
+ _order = 'model_id DESC,id'
_MODES = ['read', 'write', 'create', 'unlink']
name = fields.Char(index=True)
diff --git a/odoo/addons/base/models/ir_translation.py b/odoo/addons/base/models/ir_translation.py
index 60616552967..a9ea0d5ae07 100644
--- a/odoo/addons/base/models/ir_translation.py
+++ b/odoo/addons/base/models/ir_translation.py
@@ -608,8 +608,8 @@ class IrTranslation(models.Model):
external_ids = records.get_external_id() # if no xml_id, empty string
if callable(field.translate):
# insert missing translations for each term in src
- query = """ INSERT INTO ir_translation (lang, type, name, res_id, src, value, module)
- SELECT l.code, 'model_terms', %(name)s, %(res_id)s, %(src)s, %(src)s, %(module)s
+ query = """ INSERT INTO ir_translation (lang, type, name, res_id, src, module, state)
+ SELECT l.code, 'model_terms', %(name)s, %(res_id)s, %(src)s, %(module)s, 'to_translate'
FROM res_lang l
WHERE l.active AND l.translatable AND NOT EXISTS (
SELECT 1 FROM ir_translation
@@ -629,8 +629,8 @@ class IrTranslation(models.Model):
})
else:
# insert missing translations for src
- query = """ INSERT INTO ir_translation (lang, type, name, res_id, src, value, module)
- SELECT l.code, 'model', %(name)s, %(res_id)s, %(src)s, %(src)s, %(module)s
+ query = """ INSERT INTO ir_translation (lang, type, name, res_id, src, module, state)
+ SELECT l.code, 'model', %(name)s, %(res_id)s, %(src)s, %(module)s, 'to_translate'
FROM res_lang l
WHERE l.active AND l.translatable AND l.code != 'en_US' AND NOT EXISTS (
SELECT 1 FROM ir_translation
diff --git a/odoo/addons/base/models/res_users.py b/odoo/addons/base/models/res_users.py
index 083607f9807..5b4202da7c2 100644
--- a/odoo/addons/base/models/res_users.py
+++ b/odoo/addons/base/models/res_users.py
@@ -228,7 +228,7 @@ class Users(models.Model):
help="If specified, this action will be opened at log on for this user, in addition to the standard menu.")
groups_id = fields.Many2many('res.groups', 'res_groups_users_rel', 'uid', 'gid', string='Groups', default=_default_groups)
log_ids = fields.One2many('res.users.log', 'create_uid', string='User log entries')
- login_date = fields.Datetime(related='log_ids.create_date', string='Latest connection', readonly=False)
+ login_date = fields.Datetime(related='log_ids.create_date', string='Latest authentication', readonly=False)
share = fields.Boolean(compute='_compute_share', compute_sudo=True, string='Share User', store=True,
help="External user with limited access, created only for the purpose of sharing data.")
companies_count = fields.Integer(compute='_compute_companies_count', string="Number of Companies", default=_companies_count)
@@ -251,6 +251,13 @@ class Users(models.Model):
name = fields.Char(related='partner_id.name', inherited=True, readonly=False)
email = fields.Char(related='partner_id.email', inherited=True, readonly=False)
+ accesses_count = fields.Integer('# Access Rights', help='Number of access rights that apply to the current user',
+ compute='_compute_accesses_count')
+ rules_count = fields.Integer('# Record Rules', help='Number of record rules that apply to the current user',
+ compute='_compute_accesses_count')
+ groups_count = fields.Integer('# Groups', help='Number of groups that apply to the current user',
+ compute='_compute_accesses_count')
+
_sql_constraints = [
('login_key', 'UNIQUE (login)', 'You can not have two users with the same login !')
]
@@ -350,6 +357,14 @@ class Users(models.Model):
for user in self:
user.tz_offset = datetime.datetime.now(pytz.timezone(user.tz or 'GMT')).strftime('%z')
+ @api.depends('groups_id')
+ def _compute_accesses_count(self):
+ for user in self:
+ groups = user.groups_id
+ user.accesses_count = len(groups.mapped('model_access'))
+ user.rules_count = len(groups.mapped('rule_groups'))
+ user.groups_count = len(groups)
+
@api.onchange('login')
def on_change_login(self):
if self.login and tools.single_email_re.match(self.login):
@@ -689,7 +704,7 @@ class Users(models.Model):
:return: True if the current user is a member of the group with the
given external ID (XML ID), else False.
"""
- assert group_ext_id and '.' in group_ext_id, "External ID must be fully qualified"
+ assert group_ext_id and '.' in group_ext_id, "External ID '%s' must be fully qualified" % group_ext_id
module, ext_id = group_ext_id.split('.')
self._cr.execute("""SELECT 1 FROM res_groups_users_rel WHERE uid=%s AND gid IN
(SELECT res_id FROM ir_model_data WHERE module=%s AND name=%s)""",
@@ -698,6 +713,45 @@ class Users(models.Model):
# for a few places explicitly clearing the has_group cache
has_group.clear_cache = _has_group.clear_cache
+ def action_show_groups(self):
+ self.ensure_one()
+ return {
+ 'name': _('Groups'),
+ 'view_type': 'form',
+ 'view_mode': 'tree,form',
+ 'res_model': 'res.groups',
+ 'type': 'ir.actions.act_window',
+ 'context': {'create': False, 'delete': False},
+ 'domain': [('id','in', self.groups_id.ids)],
+ 'target': 'current',
+ }
+
+ def action_show_accesses(self):
+ self.ensure_one()
+ return {
+ 'name': _('Access Rights'),
+ 'view_type': 'form',
+ 'view_mode': 'tree,form',
+ 'res_model': 'ir.model.access',
+ 'type': 'ir.actions.act_window',
+ 'context': {'create': False, 'delete': False},
+ 'domain': [('id', 'in', self.mapped('groups_id.model_access').ids)],
+ 'target': 'current',
+ }
+
+ def action_show_rules(self):
+ self.ensure_one()
+ return {
+ 'name': _('Record Rules'),
+ 'view_type': 'form',
+ 'view_mode': 'tree,form',
+ 'res_model': 'ir.rule',
+ 'type': 'ir.actions.act_window',
+ 'context': {'create': False, 'delete': False},
+ 'domain': [('id', 'in', self.mapped('groups_id.rule_groups').ids)],
+ 'target': 'current',
+ }
+
@api.multi
def _is_public(self):
self.ensure_one()
diff --git a/odoo/addons/base/tests/test_ir_actions.py b/odoo/addons/base/tests/test_ir_actions.py
index 1ae3eef8535..865cca5e43a 100644
--- a/odoo/addons/base/tests/test_ir_actions.py
+++ b/odoo/addons/base/tests/test_ir_actions.py
@@ -105,8 +105,8 @@ class TestServerActions(TestServerActionsBase):
'crud_model_id': self.res_country_model.id,
'link_field_id': False,
'fields_lines': [(5,),
- (0, 0, {'col1': self.res_country_name_field.id, 'value': 'record.name', 'type': 'equation'}),
- (0, 0, {'col1': self.res_country_code_field.id, 'value': 'record.name[0:2]', 'type': 'equation'})],
+ (0, 0, {'col1': self.res_country_name_field.id, 'value': 'record.name', 'evaluation_type': 'equation'}),
+ (0, 0, {'col1': self.res_country_code_field.id, 'value': 'record.name[0:2]', 'evaluation_type': 'equation'})],
})
run_res = self.action.with_context(self.context).run()
self.assertFalse(run_res, 'ir_actions_server: create record action correctly finished should return False')
@@ -311,6 +311,7 @@ class TestCustomFields(common.TransactionCase):
field = self.create_field('x_foo')
field.name = 'x_bar'
+ @mute_logger('odoo.addons.base.models.ir_ui_view')
def test_remove_with_view(self):
""" try removing a custom field that occurs in a view """
field = self.create_field('x_foo')
@@ -321,6 +322,7 @@ class TestCustomFields(common.TransactionCase):
field.unlink()
self.assertIn('x_foo', self.env[self.MODEL]._fields)
+ @mute_logger('odoo.addons.base.models.ir_ui_view')
def test_rename_with_view(self):
""" try renaming a custom field that occurs in a view """
field = self.create_field('x_foo')
diff --git a/odoo/addons/base/tests/test_views.py b/odoo/addons/base/tests/test_views.py
index e1e38dd506d..92c370b3f47 100644
--- a/odoo/addons/base/tests/test_views.py
+++ b/odoo/addons/base/tests/test_views.py
@@ -1310,7 +1310,7 @@ class TestViews(ViewCase):
'arch': arch % '',
})
- @mute_logger('odoo.addons.base.ir.ir_ui_view')
+ @mute_logger('odoo.addons.base.models.ir_ui_view')
def test_domain_on_field_in_subview(self):
field = self.env['ir.ui.view']._fields['inherit_id']
self.patch(field, 'domain', "[('model', '=', model)]")
@@ -1344,7 +1344,7 @@ class TestViews(ViewCase):
'arch': arch % ('<field name="model"/>', ''),
})
- @mute_logger('odoo.addons.base.ir.ir_ui_view')
+ @mute_logger('odoo.addons.base.models.ir_ui_view')
def test_domain_on_field_in_subview_with_parent(self):
field = self.env['ir.ui.view']._fields['inherit_id']
self.patch(field, 'domain', "[('model', '=', parent.model)]")
@@ -1378,7 +1378,7 @@ class TestViews(ViewCase):
'arch': arch % ('', '<field name="model"/>'),
})
- @mute_logger('odoo.addons.base.ir.ir_ui_view')
+ @mute_logger('odoo.addons.base.models.ir_ui_view')
def test_domain_on_field_in_noneditable_subview(self):
field = self.env['ir.ui.view']._fields['inherit_id']
self.patch(field, 'domain', "[('model', '=', model)]")
@@ -1406,7 +1406,7 @@ class TestViews(ViewCase):
'arch': arch % ' editable="bottom"',
})
- @mute_logger('odoo.addons.base.ir.ir_ui_view')
+ @mute_logger('odoo.addons.base.models.ir_ui_view')
def test_domain_on_readonly_field_in_view(self):
field = self.env['ir.ui.view']._fields['inherit_id']
self.patch(field, 'domain', "[('model', '=', model)]")
@@ -1436,7 +1436,7 @@ class TestViews(ViewCase):
'arch': arch,
})
- @mute_logger('odoo.addons.base.ir.ir_ui_view')
+ @mute_logger('odoo.addons.base.models.ir_ui_view')
def test_domain_on_readonly_field_in_subview(self):
field = self.env['ir.ui.view']._fields['inherit_id']
self.patch(field, 'domain', "[('model', '=', model)]")
@@ -1464,7 +1464,7 @@ class TestViews(ViewCase):
'arch': arch % '',
})
- @mute_logger('odoo.addons.base.ir.ir_ui_view')
+ @mute_logger('odoo.addons.base.models.ir_ui_view')
def test_attrs_field(self):
arch = """
<form string="View">
diff --git a/odoo/addons/base/views/ir_actions_views.xml b/odoo/addons/base/views/ir_actions_views.xml
index 4e9e4ec650d..a81ce4b5bf9 100644
--- a/odoo/addons/base/views/ir_actions_views.xml
+++ b/odoo/addons/base/views/ir_actions_views.xml
@@ -294,7 +294,7 @@
<field name="code" widget="ace" options="{'mode': 'python'}" placeholder="Enter Python code here. Help about Python expression is available in the help tab of this document."/>
</page>
- <page string="Data to Write" autofocus="autofocus"
+ <page string="Data to Write" name="page_object" autofocus="autofocus"
attrs="{'invisible':[('state', 'not in', ['object_create', 'object_write'])]}">
<p attrs="{'invisible': [('model_id', '!=', False)]}">
Please set the Model to Create before choosing values
@@ -302,9 +302,9 @@
<field name="fields_lines">
<tree string="Field Mappings" editable="bottom">
<field name="col1" domain="['|', ('model_id', '=', parent.crud_model_id), ('model_id', '=', parent.model_id)]"/>
- <field name="type"/>
- <field name="resource_ref" attrs="{'readonly': [('type', '!=', 'reference')]}" options="{'hide_model': True}"/>
- <field name="value" attrs="{'readonly': [('type', '=', 'reference')]}" force_save="1"/>
+ <field name="evaluation_type"/>
+ <field name="resource_ref" attrs="{'readonly': [('evaluation_type', '!=', 'reference')]}" options="{'hide_model': True}"/>
+ <field name="value" attrs="{'readonly': [('evaluation_type', '=', 'reference')]}" force_save="1"/>
</tree>
</field>
</page>
@@ -318,7 +318,7 @@
<field name="child_ids" domain="[('model_id', '=', model_id)]"/>
</page>
- <page string="Help"
+ <page string="Help" autofocus="autofocus"
attrs="{'invisible': [('state', '!=', 'code')]}">
<group>
<div style="margin-top: 4px;">
diff --git a/odoo/addons/base/views/ir_default_views.xml b/odoo/addons/base/views/ir_default_views.xml
index caa12a6dc19..b3840c21fdc 100644
--- a/odoo/addons/base/views/ir_default_views.xml
+++ b/odoo/addons/base/views/ir_default_views.xml
@@ -6,16 +6,18 @@
<field name="model">ir.default</field>
<field name="arch" type="xml">
<form string="User-defined Defaults">
+ <sheet>
<group>
- <group>
+ <group name="field_value">
<field name="field_id"/>
<field name="json_value"/>
</group>
- <group>
+ <group name="user_company_details">
<field name="user_id"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
+ </sheet>
</form>
</field>
</record>
diff --git a/odoo/addons/base/views/ir_model_views.xml b/odoo/addons/base/views/ir_model_views.xml
index 60385fb46db..092f87a49bb 100644
--- a/odoo/addons/base/views/ir_model_views.xml
+++ b/odoo/addons/base/views/ir_model_views.xml
@@ -530,6 +530,25 @@
<!-- ir.model.access -->
<record id="ir_access_view_tree" model="ir.ui.view">
+ <field name="name">ir.model.access.view.tree</field>
+ <field name="model">ir.model.access</field>
+ <field name="arch" type="xml">
+ <tree string="Access Rights"
+ decoration-warning="not group_id and
+ (perm_read or perm_write or
+ perm_create or perm_unlink)">
+ <field name="name"/>
+ <field name="model_id"/>
+ <field name="group_id"/>
+ <field name="perm_read"/>
+ <field name="perm_write"/>
+ <field name="perm_create"/>
+ <field name="perm_unlink"/>
+ </tree>
+ </field>
+ </record>
+ <record id="ir_access_view_tree_edition" model="ir.ui.view">
+ <field name="name">ir.model.access.view.tree.edition</field>
<field name="model">ir.model.access</field>
<field name="arch" type="xml">
<tree string="Access Rights" editable="top"
@@ -551,6 +570,9 @@
<field name="arch" type="xml">
<form string="Access Rights">
<sheet>
+ <div class="alert alert-warning text-center" role="alert">
+ Please note that modifications will be applied for all users of the specified group
+ </div>
<group col="4">
<field name="name"/>
<field name="model_id"/>
@@ -590,7 +612,7 @@
<field name="name">Access Rights</field>
<field name="res_model">ir.model.access</field>
<field name="view_type">form</field>
- <field name="view_id" ref="ir_access_view_tree"/>
+ <field name="view_id" ref="ir_access_view_tree_edition"/>
<field name="search_view_id" ref="ir_access_view_search"/>
</record>
<menuitem action="ir_access_act" id="menu_ir_access_act" parent="base.menu_security"/>
diff --git a/odoo/addons/base/views/res_users_views.xml b/odoo/addons/base/views/res_users_views.xml
index 88aca904336..e895d88eff3 100644
--- a/odoo/addons/base/views/res_users_views.xml
+++ b/odoo/addons/base/views/res_users_views.xml
@@ -163,6 +163,15 @@
<sheet>
<field name="id" invisible="1"/>
<div class="oe_button_box" name="button_box">
+ <button name="action_show_groups" type="object" groups="base.group_no_one" class="oe_stat_button" icon="fa-users">
+ <field string="Groups" name="groups_count" widget="statinfo"/>
+ </button>
+ <button name="action_show_accesses" type="object" groups="base.group_no_one" class="oe_stat_button" icon="fa-list">
+ <field string="Access Rights" name="accesses_count" widget="statinfo"/>
+ </button>
+ <button name="action_show_rules" type="object" groups="base.group_no_one" class="oe_stat_button" icon="fa-list-ul">
+ <field string="Record Rules" name="rules_count" widget="statinfo"/>
+ </button>
<button name="toggle_active" type="object" class="oe_stat_button" icon="fa-check">
<field name="active" widget="boolean_button" options='{"terminology": "active"}'/>
</button>
diff --git a/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py b/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py
index a2b03940e2b..983f6a87d7f 100644
--- a/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py
+++ b/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py
@@ -46,9 +46,9 @@ class TestJavascriptAssetsBundle(TransactionCase):
"""
user_direction = self.env['res.lang'].search([('code', '=', (lang or self.env.user.lang))]).direction
bundle = self.jsbundle_xmlid if type == 'js' else self.cssbundle_xmlid
- return self.env['ir.attachment'].search([
- ('url', '=like', '/web/content/%-%/{0}{1}%.{2}'.format(('rtl/' if type == 'css' and user_direction == 'rtl' else ''), bundle, type))
- ])
+ url = '/web/content/%-%/{0}{1}.{2}'.format(('rtl/' if type == 'css' and user_direction == 'rtl' else ''), bundle, type)
+ domain = [('url', '=like', url)]
+ return self.env['ir.attachment'].search(domain)
def test_01_generation(self):
""" Checks that a bundle creates an ir.attachment record when its `js` method is called
@@ -161,94 +161,48 @@ class TestJavascriptAssetsBundle(TransactionCase):
# there shouldn't be any assets created in debug mode
self.assertEquals(len(self._any_ira_for_bundle('js')), 0)
- def test_06_paginated_css_generation1(self):
- """ Checks that a bundle creates enough ir.attachment records when its `css` method is called
- for the first time while the number of css rules exceed the limit.
- """
- # note: changing the max_css_rules of a bundle does not invalidate its attachments
- # self.cssbundle_xlmid contains 3 rules
- self.bundle = self._get_asset(self.cssbundle_xmlid, env=self.env(context={'max_css_rules': 1}))
- self.bundle.css()
- self.assertEquals(len(self._any_ira_for_bundle('css')), 3)
- self.assertEquals(len(self.bundle.get_attachments('css')), 3)
-
- def test_07_paginated_css_generation2(self):
+ def test_08_css_generation3(self):
# self.cssbundle_xlmid contains 3 rules
- self.bundle = self._get_asset(self.cssbundle_xmlid, env=self.env(context={'max_css_rules': 2}))
- self.bundle.css()
- self.assertEquals(len(self._any_ira_for_bundle('css')), 2)
- self.assertEquals(len(self.bundle.get_attachments('css')), 2)
-
- def test_08_paginated_css_generation3(self):
- # self.cssbundle_xlmid contains 3 rules
- self.bundle = self._get_asset(self.cssbundle_xmlid, env=self.env(context={'max_css_rules': 3}))
+ self.bundle = self._get_asset(self.cssbundle_xmlid)
self.bundle.css()
self.assertEquals(len(self._any_ira_for_bundle('css')), 1)
self.assertEquals(len(self.bundle.get_attachments('css')), 1)
- def test_09_paginated_css_access(self):
+ def test_09_css_access(self):
""" Checks that the bundle's cache is working, i.e. that a bundle creates only enough
ir.attachment records when rendered multiple times.
"""
- bundle0 = self._get_asset(self.cssbundle_xmlid, env=self.env(context={'max_css_rules': 1}))
+ bundle0 = self._get_asset(self.cssbundle_xmlid)
bundle0.css()
- self.assertEquals(len(self._any_ira_for_bundle('css')), 3)
+ self.assertEquals(len(self._any_ira_for_bundle('css')), 1)
version0 = bundle0.version
- ira0, ira1, ira2 = self._any_ira_for_bundle('css')
+ ira0 = self._any_ira_for_bundle('css')
date0 = ira0.create_date
- date1 = ira1.create_date
- date2 = ira2.create_date
- bundle1 = self._get_asset(self.cssbundle_xmlid, env=self.env(context={'max_css_rules': 1}))
+ bundle1 = self._get_asset(self.cssbundle_xmlid)
bundle1.css()
- self.assertEquals(len(self._any_ira_for_bundle('css')), 3)
+ self.assertEquals(len(self._any_ira_for_bundle('css')), 1)
version1 = bundle1.version
- ira3, ira4, ira5 = self._any_ira_for_bundle('css')
- date3 = ira1.create_date
- date4 = ira1.create_date
- date5 = ira1.create_date
+ ira1 = self._any_ira_for_bundle('css')
+ date1 = ira1.create_date
self.assertEquals(version0, version1)
- self.assertEquals(date0, date3)
- self.assertEquals(date1, date4)
- self.assertEquals(date2, date5)
-
- def test_10_paginated_css_date_invalidation(self):
- """ Checks that a bundle is invalidated when one of its assets' modification date is changed.
- """
- bundle0 = self._get_asset(self.cssbundle_xmlid, env=self.env(context={'max_css_rules': 1}))
- bundle0.css()
- last_modified0 = bundle0.last_modified
- version0 = bundle0.version
-
- path = get_resource_path('test_assetsbundle', 'static', 'src', 'css', 'test_cssfile1.css')
- bundle1 = self._get_asset(self.cssbundle_xmlid, env=self.env(context={'max_css_rules': 1}))
- _touch(path, bundle1)
-
- bundle1.css()
- last_modified1 = bundle1.last_modified
- version1 = bundle1.version
-
- self.assertNotEquals(last_modified0, last_modified1)
- self.assertNotEquals(version0, version1)
-
- # check if the previous attachment is correctly cleaned
- self.assertEquals(len(self._any_ira_for_bundle('css')), 3)
+ self.assertEquals(date0, date1)
- def test_11_paginated_css_content_invalidation(self):
+ def test_11_css_content_invalidation(self):
""" Checks that a bundle is invalidated when its content is modified by adding a file to
source.
"""
- bundle0 = self._get_asset(self.cssbundle_xmlid, env=self.env(context={'max_css_rules': 1}))
+ bundle0 = self._get_asset(self.cssbundle_xmlid)
bundle0.css()
files0 = bundle0.files
version0 = bundle0.version
- self.assertEquals(len(self._any_ira_for_bundle('css')), 3)
+ self.assertEquals(len(self._any_ira_for_bundle('css')), 1)
view_arch = """
<data>
@@ -265,7 +219,7 @@ class TestJavascriptAssetsBundle(TransactionCase):
'inherit_id': bundle.id,
})
- bundle1 = self._get_asset(self.cssbundle_xmlid, env=self.env(context={'check_view_ids': view.ids, 'max_css_rules': 1}))
+ bundle1 = self._get_asset(self.cssbundle_xmlid, env=self.env(context={'check_view_ids': view.ids}))
bundle1.css()
files1 = bundle1.files
version1 = bundle1.version
@@ -274,12 +228,12 @@ class TestJavascriptAssetsBundle(TransactionCase):
self.assertNotEquals(version0, version1)
# check if the previous attachment are correctly cleaned
- self.assertEquals(len(self._any_ira_for_bundle('css')), 4)
+ self.assertEquals(len(self._any_ira_for_bundle('css')), 1)
- def test_12_paginated_css_debug(self):
+ def test_12_css_debug(self):
""" Check that a bundle in debug mode outputs non-minified assets.
"""
- debug_bundle = self._get_asset(self.cssbundle_xmlid, env=self.env(context={'max_css_rules': 1}))
+ debug_bundle = self._get_asset(self.cssbundle_xmlid)
content = debug_bundle.to_html(debug='assets')
# find back one of the original asset file
self.assertIn('/test_assetsbundle/static/src/css/test_cssfile1.css', content)
@@ -287,15 +241,6 @@ class TestJavascriptAssetsBundle(TransactionCase):
# there shouldn't be any assets created in debug mode
self.assertEquals(len(self._any_ira_for_bundle('css')), 0)
- def test_13_paginated_css_order(self):
- # self.cssbundle_xlmid contains 3 rules
- self.bundle = self._get_asset(self.cssbundle_xmlid, env=self.env(context={'max_css_rules': 1}))
- stylesheets = self.bundle.css()
-
- self.assertTrue(stylesheets[0].url.endswith('.0.css'))
- self.assertTrue(stylesheets[1].url.endswith('.1.css'))
- self.assertTrue(stylesheets[2].url.endswith('.2.css'))
-
def test_14_duplicated_css_assets(self):
""" Checks that if the bundle's ir.attachment record is duplicated, the bundle is only sourced once. This could
happen if multiple transactions try to render the bundle simultaneously.
@@ -312,7 +257,7 @@ class TestJavascriptAssetsBundle(TransactionCase):
# the ir.attachment records should be deduplicated in the bundle's content
content = bundle0.to_html()
- self.assertEqual(content.count('test_assetsbundle.bundle2.0.css'), 1)
+ self.assertEqual(content.count('test_assetsbundle.bundle2.css'), 1)
# Language direction specific tests
diff --git a/odoo/addons/test_convert/__init__.py b/odoo/addons/test_convert/__init__.py
index e69de29bb2d..a301ab24b52 100644
--- a/odoo/addons/test_convert/__init__.py
+++ b/odoo/addons/test_convert/__init__.py
@@ -0,0 +1,10 @@
+from odoo import fields, models
+
+
+class Usered(models.Model):
+ _name = 'test_convert.usered'
+ _description = "z test model ignore"
+
+ name = fields.Char()
+ user_id = fields.Many2one('res.users', default=lambda self: self.env.user)
+ tz = fields.Char(default=lambda self: self.env.context.get('tz') or self.env.user.tz)
diff --git a/odoo/addons/test_convert/__manifest__.py b/odoo/addons/test_convert/__manifest__.py
index af6cd1af6e4..1411f093f90 100644
--- a/odoo/addons/test_convert/__manifest__.py
+++ b/odoo/addons/test_convert/__manifest__.py
@@ -5,4 +5,7 @@
'name': 'test_convert',
'description': "Data for xml conversion tests",
'version': '0.0.1',
+ 'data': [
+ 'ir.model.access.csv',
+ ]
}
diff --git a/odoo/addons/test_convert/ir.model.access.csv b/odoo/addons/test_convert/ir.model.access.csv
new file mode 100644
index 00000000000..0282a269c41
--- /dev/null
+++ b/odoo/addons/test_convert/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_test_convert_usered,access_test_convert_usered,model_test_convert_usered,base.group_user,1,1,1,1
diff --git a/odoo/addons/test_convert/tests/__init__.py b/odoo/addons/test_convert/tests/__init__.py
index 2b608870b30..14d4ce40ead 100644
--- a/odoo/addons/test_convert/tests/__init__.py
+++ b/odoo/addons/test_convert/tests/__init__.py
@@ -1,2 +1,3 @@
# -*- coding: utf-8 -*-
from . import test_convert
+from . import test_env
diff --git a/odoo/addons/test_convert/tests/test_env.py b/odoo/addons/test_convert/tests/test_env.py
new file mode 100644
index 00000000000..fc5d9a094d3
--- /dev/null
+++ b/odoo/addons/test_convert/tests/test_env.py
@@ -0,0 +1,166 @@
+# -*- coding: utf-8 -*-
+"""
+Tests the ability to update environmental information on various nodes (e.g.
+change user, add context keys, ...)
+"""
+import os.path
+
+from lxml import etree
+from lxml.builder import E
+
+from odoo.tests import common
+from odoo.tools import config
+from odoo.tools.convert import xml_import
+
+odoo = E.odoo
+data = E.data
+record = E.record
+field = E.field
+function = E.function
+
+class TestEnv(common.TransactionCase):
+ def setUp(self):
+ super().setUp()
+ self._importer = xml_import(self.env.cr, 'test_convert', None, 'init')
+
+ def importer(self, doc):
+ etree.RelaxNG(
+ etree.parse(
+ os.path.join(config['root_path'], 'import_xml.rng')
+ )
+ ).assert_(doc)
+ self._importer.parse(doc)
+
+ def test_uid_data_record(self):
+ self.importer(
+ odoo(
+ record(
+ field("a", name="name"),
+ model="test_convert.usered",
+ id="test_convert.testing"
+ ),
+ uid="base.user_demo"
+ )
+ )
+
+ r = self.env.ref('test_convert.testing')
+ self.assertEqual(r.name, 'a')
+ self.assertEqual(r.create_uid, self.env.ref('base.user_demo'))
+ self.assertEqual(r.user_id, self.env.ref('base.user_demo'))
+
+ def test_uid_data_function(self):
+ self.importer(
+ odoo(
+ function(
+ model="test_convert.usered",
+ name="create",
+ eval="[[{'name': 'b'}]]",
+ ),
+ uid="base.user_demo"
+ )
+ )
+
+ r = self.env['test_convert.usered'].search([])
+ self.assertEqual(r.name, 'b')
+ self.assertEqual(r.create_uid, self.env.ref('base.user_demo'))
+ self.assertEqual(r.user_id, self.env.ref('base.user_demo'))
+
+ def test_uid_record(self):
+ self.importer(
+ odoo(
+ record(
+ field('c', name="name"),
+ model="test_convert.usered",
+ id="test_convert.testing",
+ uid="base.user_demo"
+ ),
+ uid="base.user_root"
+ )
+ )
+
+ r = self.env.ref('test_convert.testing')
+ self.assertEqual(r.name, 'c')
+ self.assertEqual(r.create_uid, self.env.ref('base.user_demo'))
+ self.assertEqual(r.user_id, self.env.ref('base.user_demo'))
+
+
+ def test_uid_function(self):
+ self.importer(
+ odoo(
+ function(
+ model="test_convert.usered",
+ name="create",
+ uid="base.user_demo",
+ eval="[[{'name': 'd'}]]"
+ ),
+ uid="base.user_root"
+ )
+ )
+ r = self.env['test_convert.usered'].search([])
+ self.assertEqual(r.name, 'd')
+ self.assertEqual(r.create_uid, self.env.ref('base.user_demo'))
+ self.assertEqual(r.user_id, self.env.ref('base.user_demo'))
+
+ def test_context_data_function(self):
+ self.env.user.tz = 'UTC'
+ self.importer(
+ odoo(
+ function(
+ model="test_convert.usered",
+ name="create",
+ eval="[[{'name': 'e'}]]",
+ ),
+ context="{'tz': 'Asia/Kabul'}",
+ )
+ )
+ r = self.env['test_convert.usered'].search([])
+ self.assertEqual(r.name, 'e')
+ self.assertEqual(r.tz, 'Asia/Kabul')
+
+ def test_context_function(self):
+ self.env.user.tz = 'UTC'
+ self.importer(
+ odoo(
+ function(
+ model="test_convert.usered",
+ name="create",
+ context="{'tz': 'Pacific/Apia'}",
+ eval="[[{'name': 'e'}]]",
+ ),
+ context="{'tz': 'Asia/Kabul'}",
+ )
+ )
+ r = self.env['test_convert.usered'].search([])
+ self.assertEqual(r.name, 'e')
+ self.assertEqual(r.tz, 'Pacific/Apia')
+
+ def test_context_data_record(self):
+ self.env.user.tz = 'UTC'
+ self.importer(
+ odoo(
+ record(
+ field("f", name="name"),
+ model="test_convert.usered",
+ ),
+ context="{'tz': 'America/Knox_IN'}"
+ )
+ )
+ r = self.env['test_convert.usered'].search([])
+ self.assertEqual(r.name, 'f')
+ self.assertEqual(r.tz, 'America/Knox_IN')
+
+ def test_context_record(self):
+ self.env.user.tz = 'UTC'
+ self.importer(
+ odoo(
+ record(
+ field("f", name="name"),
+ model="test_convert.usered",
+ context="{'tz': 'America/Adak'}",
+ ),
+ context="{'tz': 'America/Knox_IN'}"
+ )
+ )
+ r = self.env['test_convert.usered'].search([])
+ self.assertEqual(r.name, 'f')
+ self.assertEqual(r.tz, 'America/Adak')
diff --git a/odoo/addons/test_documentation_examples/delegation.py b/odoo/addons/test_documentation_examples/delegation.py
index 54beeef77c6..a95d4f9f250 100644
--- a/odoo/addons/test_documentation_examples/delegation.py
+++ b/odoo/addons/test_documentation_examples/delegation.py
@@ -3,26 +3,31 @@
from odoo import models, fields
-class Child0(models.Model):
- _name = 'delegation.child0'
- _description = 'Delegation Child zero'
+class Screen(models.Model):
+ _name = 'delegation.screen'
+ _description = 'Screen'
- field_0 = fields.Integer()
+ size = fields.Float(string='Screen Size in inches')
-class Child1(models.Model):
- _name = 'delegation.child1'
- _description = 'Delegation Child one'
+class Keyboard(models.Model):
+ _name = 'delegation.keyboard'
+ _description = 'Keyboard'
- field_1 = fields.Integer()
+ layout = fields.Char(string='Layout')
-class Delegating(models.Model):
- _name = 'delegation.parent'
- _description = 'Delegation Parent'
+class Laptop(models.Model):
+ _name = 'delegation.laptop'
+ _description = 'Laptop'
_inherits = {
- 'delegation.child0': 'child0_id',
- 'delegation.child1': 'child1_id',
+ 'delegation.screen': 'screen_id',
+ 'delegation.keyboard': 'keyboard_id',
}
- child0_id = fields.Many2one('delegation.child0', required=True, ondelete='cascade')
- child1_id = fields.Many2one('delegation.child1', required=True, ondelete='cascade')
+ name = fields.Char(string='Name')
+ maker = fields.Char(string='Maker')
+
+ # a Laptop has a screen
+ screen_id = fields.Many2one('delegation.screen', required=True, ondelete="cascade")
+ # a Laptop has a keyboard
+ keyboard_id = fields.Many2one('delegation.keyboard', required=True, ondelete="cascade")
diff --git a/odoo/addons/test_documentation_examples/ir.model.access.csv b/odoo/addons/test_documentation_examples/ir.model.access.csv
index ae82fd2c3a9..d576bd70e8f 100644
--- a/odoo/addons/test_documentation_examples/ir.model.access.csv
+++ b/odoo/addons/test_documentation_examples/ir.model.access.csv
@@ -2,6 +2,6 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_inheritance_0,access_inheritance_0,model_inheritance_0,,1,1,1,1
access_inheritance_1,access_inheritance_1,model_inheritance_1,,1,1,1,1
access_extension_0,access_extension_0,model_extension_0,,1,1,1,1
-access_delegation_child0,access_delegation_child0,model_delegation_child0,,1,1,1,1
-access_delegation_child1,access_delegation_child1,model_delegation_child1,,1,1,1,1
-access_delegation_parent,access_delegation_parent,model_delegation_parent,,1,1,1,1
+access_delegation_screen,access_delegation_screen,model_delegation_screen,,1,1,1,1
+access_delegation_keyboard,access_delegation_keyboard,model_delegation_keyboard,,1,1,1,1
+access_delegation_laptop,access_delegation_laptop,model_delegation_laptop,,1,1,1,1
diff --git a/odoo/addons/test_documentation_examples/tests/test_delegation.py b/odoo/addons/test_documentation_examples/tests/test_delegation.py
index 327485e63ea..27bf288ceb1 100644
--- a/odoo/addons/test_documentation_examples/tests/test_delegation.py
+++ b/odoo/addons/test_documentation_examples/tests/test_delegation.py
@@ -8,9 +8,9 @@ class TestDelegation(common.TransactionCase):
def setUp(self):
super(TestDelegation, self).setUp()
env = self.env
- record = env['delegation.parent'].create({
- 'child0_id': env['delegation.child0'].create({'field_0': 0}).id,
- 'child1_id': env['delegation.child1'].create({'field_1': 1}).id,
+ record = env['delegation.laptop'].create({
+ 'screen_id': env['delegation.screen'].create({'size': 13.0}).id,
+ 'keyboard_id': env['delegation.keyboard'].create({'layout': 'QWERTY'}).id,
})
self.record = record
@@ -20,28 +20,28 @@ class TestDelegation(common.TransactionCase):
# children fields can be looked up on the parent record directly
self.assertEqual(
- record.field_0
+ record.size
,
- 0
- )
+ 13.0
+ )
self.assertEqual(
- record.field_1
+ record.layout
,
- 1
- )
+ 'QWERTY'
+ )
def test_swap_child(self):
env = self.env
record = self.record
record.write({
- 'child0_id': env['delegation.child0'].create({'field_0': 42}).id
+ 'screen_id': env['delegation.screen'].create({'size': 17.0}).id
})
- self.assertEqual(record.field_0, 42)
+ self.assertEqual(record.size, 17.0)
def test_write(self):
record = self.record
- record.write({'field_1': 4})
- self.assertEqual(record.field_1, 4)
- self.assertEqual(record.child1_id.field_1, 4)
+ record.write({'size': 14.0})
+ self.assertEqual(record.size, 14.0)
+ self.assertEqual(record.screen_id.size, 14.0)
diff --git a/odoo/addons/test_impex/tests/test_load.py b/odoo/addons/test_impex/tests/test_load.py
index ff303eea407..34d6aa97de0 100644
--- a/odoo/addons/test_impex/tests/test_load.py
+++ b/odoo/addons/test_impex/tests/test_load.py
@@ -59,7 +59,7 @@ class ImporterCase(common.TransactionCase):
return '%s.%s' % (d['module'], d['name'])
return d['name']
- name = record.name_get()[0][1]
+ name = record.display_name
# fix dotted name_get results, otherwise xid lookups blow up
name = name.replace('.', '-')
ModelData.create({
@@ -761,7 +761,7 @@ class test_m2m(ImporterCase):
record2 = self.env['export.many2many.other'].create({'value': 84, 'str': 'record2'})
record3 = self.env['export.many2many.other'].create({'value': 9, 'str': 'record3'})
- name = lambda record: record.name_get()[0][1]
+ name = lambda record: record.display_name
result = self.import_(['value'], [
['%s,%s' % (name(record1), name(record2))],
diff --git a/odoo/addons/test_performance/models/models.py b/odoo/addons/test_performance/models/models.py
index 8189e9b8c0d..f1c51453b47 100644
--- a/odoo/addons/test_performance/models/models.py
+++ b/odoo/addons/test_performance/models/models.py
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from odoo import models, fields, api
+from odoo import models, fields, api, tools
class BaseModel(models.Model):
@@ -35,6 +35,10 @@ class LineModel(models.Model):
base_id = fields.Many2one('test_performance.base', required=True, ondelete='cascade')
value = fields.Integer()
+ @api.model_cr
+ def init(self):
+ # line values should be unique per "base" - useful for testing corner cases with unique lines
+ tools.create_unique_index(self._cr, 'test_performance_line_uniq', self._table, ['base_id', 'value'])
class TagModel(models.Model):
_name = 'test_performance.tag'
diff --git a/odoo/addons/test_performance/tests/test_performance.py b/odoo/addons/test_performance/tests/test_performance.py
index f77aa05004c..e81c1175265 100644
--- a/odoo/addons/test_performance/tests/test_performance.py
+++ b/odoo/addons/test_performance/tests/test_performance.py
@@ -5,7 +5,7 @@ from collections import defaultdict
import json
from odoo.tests.common import TransactionCase, users, warmup
-from odoo.tools import pycompat
+from odoo.tools import pycompat, mute_logger
class TestPerformance(TransactionCase):
@@ -52,6 +52,246 @@ class TestPerformance(TransactionCase):
with self.assertQueryCount(__system__=3, demo=3):
records.write({'value': 42})
+ @mute_logger('odoo.models.unlink')
+ @users('__system__', 'demo')
+ @warmup
+ def test_write_base_one2many(self):
+ """ Write on one2many field. """
+ rec1 = self.env['test_performance.base'].create({'name': 'X'})
+
+ # create N lines on rec1: O(N) queries
+ rec1.invalidate_cache()
+ with self.assertQueryCount(9):
+ rec1.write({'line_ids': [(0, 0, {'value': 0})]})
+ self.assertEqual(len(rec1.line_ids), 1)
+
+ rec1.invalidate_cache()
+ with self.assertQueryCount(19):
+ rec1.write({'line_ids': [(0, 0, {'value': val}) for val in range(1, 12)]})
+ self.assertEqual(len(rec1.line_ids), 12)
+
+ lines = rec1.line_ids
+
+ # update N lines: O(N) queries
+ rec1.invalidate_cache()
+ with self.assertQueryCount(11):
+ rec1.write({'line_ids': [(1, line.id, {'value': 42}) for line in lines[0]]})
+ self.assertEqual(rec1.line_ids, lines)
+
+ rec1.invalidate_cache()
+ with self.assertQueryCount(61):
+ rec1.write({'line_ids': [(1, line.id, {'value': 42 + line.id}) for line in lines[1:]]})
+ self.assertEqual(rec1.line_ids, lines)
+
+ # delete N lines: O(1) queries
+ rec1.invalidate_cache()
+ with self.assertQueryCount(__system__=16, demo=18):
+ rec1.write({'line_ids': [(2, line.id) for line in lines[0]]})
+ self.assertEqual(rec1.line_ids, lines[1:])
+
+ rec1.invalidate_cache()
+ with self.assertQueryCount(__system__=14, demo=16):
+ rec1.write({'line_ids': [(2, line.id) for line in lines[1:]]})
+ self.assertFalse(rec1.line_ids)
+ self.assertFalse(lines.exists())
+
+ rec1.write({'line_ids': [(0, 0, {'value': val}) for val in range(12)]})
+ lines = rec1.line_ids
+
+ # unlink N lines: O(1) queries
+ rec1.invalidate_cache()
+ with self.assertQueryCount(__system__=16, demo=18):
+ rec1.write({'line_ids': [(3, line.id) for line in lines[0]]})
+ self.assertEqual(rec1.line_ids, lines[1:])
+
+ rec1.invalidate_cache()
+ with self.assertQueryCount(__system__=14, demo=16):
+ rec1.write({'line_ids': [(3, line.id) for line in lines[1:]]})
+ self.assertFalse(rec1.line_ids)
+ self.assertFalse(lines.exists())
+
+ rec1.write({'line_ids': [(0, 0, {'value': val}) for val in range(12)]})
+ lines = rec1.line_ids
+ rec2 = self.env['test_performance.base'].create({'name': 'X'})
+
+ # link N lines from rec1 to rec2: O(1) queries
+ rec1.invalidate_cache()
+ with self.assertQueryCount(13):
+ rec2.write({'line_ids': [(4, line.id) for line in lines[0]]})
+ self.assertEqual(rec1.line_ids, lines[1:])
+ self.assertEqual(rec2.line_ids, lines[0])
+
+ rec1.invalidate_cache()
+ with self.assertQueryCount(13):
+ rec2.write({'line_ids': [(4, line.id) for line in lines[1:]]})
+ self.assertFalse(rec1.line_ids)
+ self.assertEqual(rec2.line_ids, lines)
+
+ rec1.invalidate_cache()
+ with self.assertQueryCount(7):
+ rec2.write({'line_ids': [(4, line.id) for line in lines[0]]})
+ self.assertEqual(rec2.line_ids, lines)
+
+ rec1.invalidate_cache()
+ with self.assertQueryCount(7):
+ rec2.write({'line_ids': [(4, line.id) for line in lines[1:]]})
+ self.assertEqual(rec2.line_ids, lines)
+
+ # empty N lines in rec2: O(1) queries
+ rec1.invalidate_cache()
+ with self.assertQueryCount(__system__=15, demo=17):
+ rec2.write({'line_ids': [(5,)]})
+ self.assertFalse(rec2.line_ids)
+
+ rec1.invalidate_cache()
+ with self.assertQueryCount(5):
+ rec2.write({'line_ids': [(5,)]})
+ self.assertFalse(rec2.line_ids)
+
+ rec1.write({'line_ids': [(0, 0, {'value': val}) for val in range(12)]})
+ lines = rec1.line_ids
+
+ # set N lines in rec2: O(1) queries
+ rec1.invalidate_cache()
+ with self.assertQueryCount(14):
+ rec2.write({'line_ids': [(6, 0, lines[0].ids)]})
+ self.assertEqual(rec1.line_ids, lines[1:])
+ self.assertEqual(rec2.line_ids, lines[0])
+
+ with self.assertQueryCount(13):
+ rec2.write({'line_ids': [(6, 0, lines.ids)]})
+ self.assertFalse(rec1.line_ids)
+ self.assertEqual(rec2.line_ids, lines)
+
+ with self.assertQueryCount(7):
+ rec2.write({'line_ids': [(6, 0, lines.ids)]})
+ self.assertEqual(rec2.line_ids, lines)
+
+ @mute_logger('odoo.models.unlink')
+ def test_write_base_one2many_with_constraint(self):
+ """ Write on one2many field with lines being deleted and created. """
+ rec = self.env['test_performance.base'].create({'name': 'Y'})
+ rec.write({'line_ids': [(0, 0, {'value': val}) for val in range(12)]})
+
+ # This write() will raise because of the unique index if the unlink() is
+ # not performed before the create()
+ rec.write({'line_ids': [(5,)] + [(0, 0, {'value': val}) for val in range(6)]})
+ self.assertEquals(len(rec.line_ids), 6)
+
+ @mute_logger('odoo.models.unlink')
+ @users('__system__', 'demo')
+ @warmup
+ def test_write_base_many2many(self):
+ """ Write on many2many field. """
+ rec1 = self.env['test_performance.base'].create({'name': 'X'})
+
+ # create N tags on rec1: O(N) queries
+ rec1.invalidate_cache()
+ with self.assertQueryCount(4):
+ rec1.write({'tag_ids': [(0, 0, {'name': 0})]})
+ self.assertEqual(len(rec1.tag_ids), 1)
+
+ rec1.invalidate_cache()
+ with self.assertQueryCount(14):
+ rec1.write({'tag_ids': [(0, 0, {'name': val}) for val in range(1, 12)]})
+ self.assertEqual(len(rec1.tag_ids), 12)
+
+ tags = rec1.tag_ids
+
+ # update N tags: O(N) queries
+ rec1.invalidate_cache()
+ with self.assertQueryCount(3):
+ rec1.write({'tag_ids': [(1, tag.id, {'name': 'X'}) for tag in tags[0]]})
+ self.assertEqual(rec1.tag_ids, tags)
+
+ rec1.invalidate_cache()
+ with self.assertQueryCount(13):
+ rec1.write({'tag_ids': [(1, tag.id, {'name': 'X'}) for tag in tags[1:]]})
+ self.assertEqual(rec1.tag_ids, tags)
+
+ # delete N tags: O(1) queries
+ rec1.invalidate_cache()
+ with self.assertQueryCount(__system__=10, demo=12):
+ rec1.write({'tag_ids': [(2, tag.id) for tag in tags[0]]})
+ self.assertEqual(rec1.tag_ids, tags[1:])
+
+ rec1.invalidate_cache()
+ with self.assertQueryCount(__system__=10, demo=12):
+ rec1.write({'tag_ids': [(2, tag.id) for tag in tags[1:]]})
+ self.assertFalse(rec1.tag_ids)
+ self.assertFalse(tags.exists())
+
+ rec1.write({'tag_ids': [(0, 0, {'name': val}) for val in range(12)]})
+ tags = rec1.tag_ids
+
+ # unlink N tags: O(1) queries
+ rec1.invalidate_cache()
+ with self.assertQueryCount(3):
+ rec1.write({'tag_ids': [(3, tag.id) for tag in tags[0]]})
+ self.assertEqual(rec1.tag_ids, tags[1:])
+
+ rec1.invalidate_cache()
+ with self.assertQueryCount(3):
+ rec1.write({'tag_ids': [(3, tag.id) for tag in tags[1:]]})
+ self.assertFalse(rec1.tag_ids)
+ self.assertTrue(tags.exists())
+
+ rec2 = self.env['test_performance.base'].create({'name': 'X'})
+
+ # link N tags from rec1 to rec2: O(1) queries
+ rec1.invalidate_cache()
+ with self.assertQueryCount(3):
+ rec2.write({'tag_ids': [(4, tag.id) for tag in tags[0]]})
+ self.assertEqual(rec2.tag_ids, tags[0])
+
+ rec1.invalidate_cache()
+ with self.assertQueryCount(3):
+ rec2.write({'tag_ids': [(4, tag.id) for tag in tags[1:]]})
+ self.assertEqual(rec2.tag_ids, tags)
+
+ rec1.invalidate_cache()
+ with self.assertQueryCount(2):
+ rec2.write({'tag_ids': [(4, tag.id) for tag in tags[1:]]})
+ self.assertEqual(rec2.tag_ids, tags)
+
+ # empty N tags in rec2: O(1) queries
+ rec1.invalidate_cache()
+ with self.assertQueryCount(3):
+ rec2.write({'tag_ids': [(5,)]})
+ self.assertFalse(rec2.tag_ids)
+ self.assertTrue(tags.exists())
+
+ rec1.invalidate_cache()
+ with self.assertQueryCount(2):
+ rec2.write({'tag_ids': [(5,)]})
+ self.assertFalse(rec2.tag_ids)
+
+ # set N tags in rec2: O(1) queries
+ rec1.invalidate_cache()
+ with self.assertQueryCount(3):
+ rec2.write({'tag_ids': [(6, 0, tags.ids)]})
+ self.assertEqual(rec2.tag_ids, tags)
+
+ rec1.invalidate_cache()
+ with self.assertQueryCount(3):
+ rec2.write({'tag_ids': [(6, 0, tags[:8].ids)]})
+ self.assertEqual(rec2.tag_ids, tags[:8])
+
+ rec1.invalidate_cache()
+ with self.assertQueryCount(4):
+ rec2.write({'tag_ids': [(6, 0, tags[4:].ids)]})
+ self.assertEqual(rec2.tag_ids, tags[4:])
+
+ rec1.invalidate_cache()
+ with self.assertQueryCount(3):
+ rec2.write({'tag_ids': [(6, 0, tags.ids)]})
+ self.assertEqual(rec2.tag_ids, tags)
+
+ rec1.invalidate_cache()
+ with self.assertQueryCount(2):
+ rec2.write({'tag_ids': [(6, 0, tags.ids)]})
+ self.assertEqual(rec2.tag_ids, tags)
+
@users('__system__', 'demo')
@warmup
def test_create_base(self):
@@ -73,12 +313,37 @@ class TestPerformance(TransactionCase):
@warmup
def test_create_base_with_tags(self):
""" Create records with many2many tags. """
- with self.assertQueryCount(__system__=17, demo=17):
+ with self.assertQueryCount(6):
+ self.env['test_performance.base'].create({'name': 'X'})
+
+ # create N tags: add O(N) queries
+ with self.assertQueryCount(17):
self.env['test_performance.base'].create({
'name': 'X',
'tag_ids': [(0, 0, {'name': val}) for val in range(10)],
})
+ # link N tags: add O(1) queries
+ tags = self.env['test_performance.tag'].create([{'name': val} for val in range(10)])
+
+ with self.assertQueryCount(7):
+ self.env['test_performance.base'].create({
+ 'name': 'X',
+ 'tag_ids': [(4, tag.id) for tag in tags],
+ })
+
+ with self.assertQueryCount(6):
+ self.env['test_performance.base'].create({
+ 'name': 'X',
+ 'tag_ids': [(6, 0, [])],
+ })
+
+ with self.assertQueryCount(7):
+ self.env['test_performance.base'].create({
+ 'name': 'X',
+ 'tag_ids': [(6, 0, tags.ids)],
+ })
+
@users('__system__', 'demo')
@warmup
def test_several_prefetch(self):
diff --git a/odoo/fields.py b/odoo/fields.py
index 00923886d0a..d877cd71f87 100644
--- a/odoo/fields.py
+++ b/odoo/fields.py
@@ -24,7 +24,7 @@ import psycopg2
from .sql_db import LazyCursor
from .tools import float_repr, float_round, frozendict, html_sanitize, human_size, pg_varchar,\
- ustr, OrderedSet, pycompat, sql, date_utils
+ ustr, OrderedSet, pycompat, sql, date_utils, groupby
from .tools import DEFAULT_SERVER_DATE_FORMAT as DATE_FORMAT
from .tools import DEFAULT_SERVER_DATETIME_FORMAT as DATETIME_FORMAT
from .tools.translate import html_translate, _
@@ -1997,7 +1997,7 @@ class Reference(Selection):
return "%s,%s" % (value._name, value.id) if value else False
def convert_to_export(self, value, record):
- return value.name_get()[0][1] if value else ''
+ return value.display_name if value else ''
def convert_to_display_name(self, value, record):
return ustr(value and value.display_name)
@@ -2155,7 +2155,7 @@ class Many2one(_Relational):
return value.id
def convert_to_export(self, value, record):
- return value.name_get()[0][1] if value else ''
+ return value.display_name if value else ''
def convert_to_display_name(self, value, record):
return ustr(value.display_name)
@@ -2400,100 +2400,73 @@ class One2many(_RelationalMulti):
cache.set(record, self, tuple(group[record.id]))
def create(self, record_values):
- if not record_values:
+ self._write(record_values)
+
+ def write(self, records, value):
+ self._write([(records, value)])
+
+ def _write(self, records_commands_list):
+ # records_commands_list = [(records, commands), ...]
+ if not records_commands_list:
return
- model = record_values[0][0]
+ model = records_commands_list[0][0].browse()
comodel = model.env[self.comodel_name].with_context(**self.context)
inverse = self.inverse_name
- vals_list = [] # vals for lines to create in batch
- def flush():
- if vals_list:
- comodel.create(vals_list)
- vals_list.clear()
+ to_create = [] # line vals to create
+ to_delete = [] # line ids to delete
+ to_relink = {} # lines to relink {line_id: record_id}
- def drop(lines):
+ def unlink(line_ids):
if comodel._fields[inverse].ondelete == 'cascade':
- lines.unlink()
+ to_delete.extend(line_ids)
else:
- lines.write({inverse: False})
+ to_relink.update(dict.fromkeys(line_ids, False))
+
+ def flush():
+ if to_delete:
+ comodel.browse(to_delete).unlink()
+ to_delete.clear()
+ if to_create:
+ comodel.create(to_create)
+ to_create.clear()
+ if to_relink:
+ prefetch = comodel.browse(to_relink)._prefetch
+ comodel_sudo = comodel.sudo().with_context(prefetch_fields=False)
+ # group lines by record, and relink them in batch
+ groups = groupby(to_relink, to_relink.get)
+ for record_id, line_ids in groups:
+ lines = comodel_sudo.browse(line_ids, prefetch).filtered(
+ lambda line: int(line[inverse]) != record_id
+ )
+ if lines:
+ comodel.browse(lines._ids).write({inverse: record_id})
+ to_relink.clear()
with model.env.norecompute():
- for record, value in record_values:
- for act in (value or []):
+ for records, commands in records_commands_list:
+ for act in (commands or ()):
if act[0] == 0:
- vals_list.append(dict(act[2], **{inverse: record.id}))
+ for record in records:
+ to_create.append(dict(act[2], **{inverse: record.id}))
elif act[0] == 1:
comodel.browse(act[1]).write(act[2])
elif act[0] == 2:
- comodel.browse(act[1]).unlink()
+ to_delete.append(act[1])
elif act[0] == 3:
- drop(comodel.browse(act[1]))
+ unlink([act[1]])
elif act[0] == 4:
- line = comodel.browse(act[1])
- line_sudo = line.sudo().with_context(prefetch_fields=False)
- if int(line_sudo[inverse]) != record.id:
- line.write({inverse: record.id})
- elif act[0] == 5:
+ to_relink[act[1]] = records[-1].id
+ elif act[0] in (5, 6):
flush()
- domain = self.domain(record) if callable(self.domain) else self.domain
- domain = domain + [(inverse, '=', record.id)]
- drop(comodel.search(domain))
- elif act[0] == 6:
- flush()
- comodel.browse(act[2]).write({inverse: record.id})
- domain = self.domain(record) if callable(self.domain) else self.domain
- domain = domain + [(inverse, '=', record.id), ('id', 'not in', act[2] or [0])]
- drop(comodel.search(domain))
-
- flush()
-
- def write(self, records, value):
- comodel = records.env[self.comodel_name].with_context(**self.context)
- inverse = self.inverse_name
- vals_list = [] # vals for lines to create in batch
-
- def flush():
- if vals_list:
- comodel.create(vals_list)
- vals_list.clear()
-
- def drop(lines):
- if comodel._fields[inverse].ondelete == 'cascade':
- lines.unlink()
- else:
- lines.write({inverse: False})
-
- with records.env.norecompute():
- for act in (value or []):
- if act[0] == 0:
- for record in records:
- vals_list.append(dict(act[2], **{inverse: record.id}))
- elif act[0] == 1:
- comodel.browse(act[1]).write(act[2])
- elif act[0] == 2:
- comodel.browse(act[1]).unlink()
- elif act[0] == 3:
- drop(comodel.browse(act[1]))
- elif act[0] == 4:
- record = records[-1]
- line = comodel.browse(act[1])
- line_sudo = line.sudo().with_context(prefetch_fields=False)
- if int(line_sudo[inverse]) != record.id:
- line.write({inverse: record.id})
- elif act[0] == 5:
- flush()
- domain = self.domain(records) if callable(self.domain) else self.domain
- domain = domain + [(inverse, 'in', records.ids)]
- drop(comodel.search(domain))
- elif act[0] == 6:
- flush()
- record = records[-1]
- comodel.browse(act[2]).write({inverse: record.id})
- domain = self.domain(records) if callable(self.domain) else self.domain
- domain = domain + [(inverse, 'in', records.ids), ('id', 'not in', act[2] or [0])]
- drop(comodel.search(domain))
+ ids = act[2] if act[0] == 6 else []
+ domain = self.domain(model) if callable(self.domain) else self.domain
+ domain = domain + [(inverse, 'in', records.ids)]
+ if ids:
+ domain = domain + [('id', 'not in', ids)]
+ unlink(comodel.search(domain)._ids)
+ to_relink.update(dict.fromkeys(ids, records[-1].id))
flush()
@@ -2650,145 +2623,127 @@ class Many2many(_RelationalMulti):
cache.set(record, self, tuple(group[record.id]))
def create(self, record_values):
- if not record_values:
- return
-
- model = record_values[0][0]
- comodel = model.env[self.comodel_name]
-
- # determine relation {id1: ids2}
- rel_ids = {record.id: set() for record, value in record_values}
- recs, vals_list = [], []
-
- def flush():
- # create lines in batch, and add new links to them
- if vals_list:
- lines = comodel.create(vals_list)
- for rec, line in pycompat.izip(recs, lines):
- rel_ids[rec.id].add(line.id)
- recs.clear()
- vals_list.clear()
-
- for record, value in record_values:
- for act in value or []:
- if not isinstance(act, (list, tuple)) or not act:
- continue
- if act[0] == 0:
- recs.append(record)
- vals_list.append(act[2])
- elif act[0] == 1:
- comodel.browse(act[1]).write(act[2])
- elif act[0] == 2:
- comodel.browse(act[1]).unlink()
- rel_ids[record.id].discard(act[1])
- elif act[0] == 3:
- rel_ids[record.id].discard(act[1])
- elif act[0] == 4:
- rel_ids[record.id].add(act[1])
- elif act[0] in (5, 6):
- if recs and recs[-1] == record:
- flush()
- rel_ids[record.id].clear()
- if act[0] == 6:
- rel_ids[record.id].update(act[2])
-
- flush()
-
- # add links
- links = [(id1, id2) for id1, ids2 in rel_ids.items() for id2 in ids2]
- if links:
- query = """
- INSERT INTO {rel} ({id1}, {id2}) VALUES {values}
- """.format(
- rel=self.relation, id1=self.column1, id2=self.column2,
- values=", ".join(["%s"] * len(links)),
- )
- model.env.cr.execute(query, tuple(links))
+ self._write(record_values, create=True)
def write(self, records, value):
- if not value:
- return
-
- cr = records.env.cr
- comodel = records.env[self.comodel_name]
-
- # determine old links (set of pairs (id1, id2))
- clauses, params, tables = comodel.env['ir.rule'].domain_get(comodel._name)
- if '"%s"' % self.relation not in tables:
- tables.append('"%s"' % self.relation)
- query = """
- SELECT {rel}.{id1}, {rel}.{id2} FROM {tables}
- WHERE {rel}.{id1} IN %s AND {rel}.{id2}={table}.id AND {cond}
- """.format(
- rel=self.relation, id1=self.column1, id2=self.column2,
- table=comodel._table, tables=",".join(tables),
- cond=" AND ".join(clauses) if clauses else "1=1",
- )
- cr.execute(query, [tuple(records.ids)] + params)
- old_links = set(cr.fetchall())
-
- # determine new links (set of pairs (id1, id2))
- new_links = set(old_links)
- vals_list = []
+ self._write([(records, value)])
- def pairs(ids2):
- for id1 in records.ids:
- for id2 in ids2:
- yield (id1, id2)
+ def _write(self, records_commands_list, create=False):
+ # records_commands_list = [(records, commands), ...]
+ if not records_commands_list:
+ return
- def flush():
- # create lines in batch, and add new links to them
- if vals_list:
- lines = comodel.create(vals_list)
- new_links.update(pairs(lines.ids))
- vals_list.clear()
-
- for act in value:
- if not isinstance(act, (list, tuple)) or not act:
- continue
- if act[0] == 0:
- vals_list.append(act[2])
- elif act[0] == 1:
- comodel.browse(act[1]).write(act[2])
- elif act[0] == 2:
- comodel.browse(act[1]).unlink()
- new_links.difference_update(pairs([act[1]]))
- elif act[0] == 3:
- new_links.difference_update(pairs([act[1]]))
- elif act[0] == 4:
- new_links.update(pairs([act[1]]))
- elif act[0] in (5, 6):
- flush()
- new_links.clear()
- if act[0] == 6:
- new_links.update(pairs(act[2]))
-
- flush()
-
- # add links (beware of duplicates)
- links = new_links - old_links
- if links:
+ model = records_commands_list[0][0].browse()
+ comodel = model.env[self.comodel_name].with_context(**self.context)
+ cr = model.env.cr
+
+ # determine old relation {x: ys}
+ old_relation = defaultdict(set)
+ if not create:
+ clauses, params, tables = comodel.env['ir.rule'].domain_get(comodel._name)
+ if '"%s"' % self.relation not in tables:
+ tables.append('"%s"' % self.relation)
query = """
- INSERT INTO {rel} ({id1}, {id2})
- VALUES {values}
- ON CONFLICT DO NOTHING
+ SELECT {rel}.{id1}, {rel}.{id2} FROM {tables}
+ WHERE {rel}.{id1} IN %s AND {rel}.{id2}={table}.id AND {cond}
""".format(
rel=self.relation, id1=self.column1, id2=self.column2,
- values=", ".join(["%s"] * len(links)),
+ table=comodel._table, tables=",".join(tables),
+ cond=" AND ".join(clauses) if clauses else "1=1",
)
- cr.execute(query, tuple(links))
+ ids = {rid for recs, cs in records_commands_list for rid in recs.ids}
+ cr.execute(query, [tuple(ids)] + params)
+ for x, y in cr.fetchall():
+ old_relation[x].add(y)
+
+ # determine new relation {x: ys}
+ new_relation = defaultdict(set)
+ for x, ys in old_relation.items():
+ new_relation[x] = set(ys)
+
+ # operations on new relation
+ def relation_add(xs, y):
+ for x in xs:
+ new_relation[x].add(y)
+
+ def relation_remove(xs, y):
+ for x in xs:
+ new_relation[x].discard(y)
+
+ def relation_set(xs, ys):
+ for x in xs:
+ new_relation[x] = set(ys)
+
+ def relation_delete(ys):
+ # the pairs (x, y) have been cascade-deleted from relation
+ for ys1 in old_relation.values():
+ ys1.difference_update(ys)
+ for ys1 in new_relation.values():
+ ys1.difference_update(ys)
+
+ to_create = [] # line vals to create [(ids, vals)]
+ to_delete = [] # line ids to delete
- # remove links
- links = old_links - new_links
- if links:
- cond = "{id1}=%s AND {id2}=%s".format(id1=self.column1, id2=self.column2)
- query = """
- DELETE FROM {rel} WHERE {cond}
- """.format(
- rel=self.relation,
- cond=" OR ".join([cond] * len(links)),
+ with model.env.norecompute():
+ for records, commands in records_commands_list:
+ for act in (commands or ()):
+ if not isinstance(act, (list, tuple)) or not act:
+ continue
+ if act[0] == 0:
+ to_create.append((records._ids, act[2]))
+ elif act[0] == 1:
+ comodel.browse(act[1]).write(act[2])
+ elif act[0] == 2:
+ to_delete.append(act[1])
+ elif act[0] == 3:
+ relation_remove(records._ids, act[1])
+ elif act[0] == 4:
+ relation_add(records._ids, act[1])
+ elif act[0] in (5, 6):
+ # new lines must no longer be linked to records
+ to_create = [(set(ids) - set(records._ids), vals)
+ for (ids, vals) in to_create]
+ relation_set(records._ids, act[2] if act[0] == 6 else ())
+
+ if to_create:
+ # create lines in batch, and link them
+ lines = comodel.create([vals for ids, vals in to_create])
+ for line, (ids, vals) in pycompat.izip(lines, to_create):
+ relation_add(ids, line.id)
+
+ if to_delete:
+ # delete lines in batch
+ comodel.browse(to_delete).unlink()
+ relation_delete(to_delete)
+
+ # process pairs to add (beware of duplicates)
+ pairs = [(x, y) for x, ys in new_relation.items() for y in ys - old_relation[x]]
+ if pairs:
+ query = "INSERT INTO {} ({}, {}) VALUES {} ON CONFLICT DO NOTHING".format(
+ self.relation, self.column1, self.column2, ", ".join(["%s"] * len(pairs)),
+ )
+ cr.execute(query, pairs)
+
+ # process pairs to remove
+ pairs = [(x, y) for x, ys in old_relation.items() for y in ys - new_relation[x]]
+ if pairs:
+ # express pairs as the union of cartesian products:
+ # pairs = [(1, 11), (1, 12), (1, 13), (2, 11), (2, 12), (2, 14)]
+ # -> y_to_xs = {11: {1, 2}, 12: {1, 2}, 13: {1}, 14: {2}}
+ # -> xs_to_ys = {{1, 2}: {11, 12}, {2}: {14}, {1}: {13}}
+ y_to_xs = defaultdict(set)
+ for x, y in pairs:
+ y_to_xs[y].add(x)
+ xs_to_ys = defaultdict(set)
+ for y, xs in y_to_xs.items():
+ xs_to_ys[frozenset(xs)].add(y)
+ # delete the rows where (id1 IN xs AND id2 IN ys) OR ...
+ COND = "{} IN %s AND {} IN %s".format(self.column1, self.column2)
+ query = "DELETE FROM {} WHERE {}".format(
+ self.relation, " OR ".join([COND] * len(xs_to_ys)),
)
- cr.execute(query, tuple(arg for pair in links for arg in pair))
+ params = [arg for xs, ys in xs_to_ys.items() for arg in [tuple(xs), tuple(ys)]]
+ cr.execute(query, params)
class Id(Field):
diff --git a/odoo/import_xml.rng b/odoo/import_xml.rng
index 52ff0f1b420..244227ac239 100644
--- a/odoo/import_xml.rng
+++ b/odoo/import_xml.rng
@@ -1,6 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<rng:grammar xmlns:rng="http://relaxng.org/ns/structure/1.0"
datatypeLibrary="http://www.w3.org/2001/XMLSchema-datatypes">
+ <rng:define name="env">
+ <rng:optional><rng:attribute name="uid" /></rng:optional>
+ <rng:optional><rng:attribute name="context" /></rng:optional>
+ </rng:define>
+
<rng:define name="any">
<rng:element>
<rng:anyName/>
@@ -37,9 +42,7 @@
<rng:element name="function">
<rng:attribute name="model" />
<rng:attribute name="name" />
- <!-- doesn't look like it has any use? -->
- <rng:optional><rng:attribute name="id" /></rng:optional>
- <rng:optional><rng:attribute name="context" /></rng:optional>
+ <rng:ref name="env"/>
<rng:optional>
<rng:choice>
<rng:attribute name="eval" />
@@ -163,7 +166,7 @@
</rng:optional>
</rng:optional>
<rng:attribute name="model" />
- <rng:optional><rng:attribute name="context"/></rng:optional>
+ <rng:ref name="env"/>
<rng:zeroOrMore>
<rng:ref name="field" />
</rng:zeroOrMore>
@@ -232,47 +235,6 @@
</rng:element>
</rng:define>
- <!-- TODO: remove these, useless -->
-
- <rng:define name="assert">
- <rng:element name="assert">
- <rng:attribute name="model" />
- <rng:optional><rng:attribute name="search" /></rng:optional>
- <rng:optional><rng:attribute name="count" /></rng:optional>
- <rng:optional><rng:attribute name="string" /></rng:optional>
- <rng:optional><rng:attribute name="id" /></rng:optional>
- <rng:optional><rng:attribute name="context" /></rng:optional>
- <rng:optional><rng:attribute name="severity" /></rng:optional>
- <rng:zeroOrMore>
- <rng:element name="test">
- <rng:attribute name="expr"/>
- <rng:choice>
- <rng:text />
- <rng:empty />
- </rng:choice>
- </rng:element>
- </rng:zeroOrMore>
- </rng:element>
- </rng:define>
-
- <rng:define name="ir_set">
- <rng:element name="ir_set">
- <rng:oneOrMore>
- <rng:ref name="field" />
- </rng:oneOrMore>
- </rng:element>
- </rng:define>
-
- <rng:define name="url">
- <rng:element name="url">
- <rng:attribute name="id" />
- <rng:attribute name="name" />
- <rng:attribute name="url" />
- <rng:optional><rng:attribute name="target" /></rng:optional>
- <rng:empty />
- </rng:element>
- </rng:define>
-
<rng:define name="act_window">
<rng:element name="act_window">
<rng:attribute name="id" />
@@ -303,7 +265,7 @@
<rng:name>data</rng:name>
</rng:choice>
<rng:optional><rng:attribute name="noupdate" /></rng:optional>
- <rng:optional><rng:attribute name="context" /></rng:optional>
+ <rng:ref name="env"/>
<rng:zeroOrMore>
<rng:choice>
<rng:text/>
@@ -313,11 +275,8 @@
<rng:ref name="template" />
<rng:ref name="delete" />
<rng:ref name="act_window" />
- <rng:ref name="url" />
- <rng:ref name="assert" />
<rng:ref name="report" />
<rng:ref name="function" />
- <rng:ref name="ir_set" />
</rng:choice>
</rng:zeroOrMore>
</rng:element>
diff --git a/odoo/release.py b/odoo/release.py
index 7ce6ea97cf7..6997f807039 100644
--- a/odoo/release.py
+++ b/odoo/release.py
@@ -12,7 +12,7 @@ RELEASE_LEVELS_DISPLAY = {ALPHA: ALPHA,
# properly comparable using normal operarors, for example:
# (6,1,0,'beta',0) < (6,1,0,'candidate',1) < (6,1,0,'candidate',2)
# (6,1,0,'candidate',2) < (6,1,0,'final',0) < (6,1,2,'final',0)
-version_info = (12, 0, 0, FINAL, 0, '')
+version_info = (13, 0, 0, ALPHA, 1, '')
version = '.'.join(str(s) for s in version_info[:2]) + RELEASE_LEVELS_DISPLAY[version_info[3]] + str(version_info[4] or '') + version_info[5]
series = serie = major_version = '.'.join(str(s) for s in version_info[:2])
diff --git a/odoo/service/db.py b/odoo/service/db.py
index 13ba7b7e735..df641cb2c50 100644
--- a/odoo/service/db.py
+++ b/odoo/service/db.py
@@ -10,6 +10,7 @@ import traceback
from xml.etree import ElementTree as ET
import zipfile
+from pytz import country_timezones
from functools import wraps
from contextlib import closing
from decorator import decorator
@@ -71,6 +72,9 @@ def _initialize_db(id, db_name, demo, lang, user_password, login='admin', countr
if country_code:
country = env['res.country'].search([('code', 'ilike', country_code)])[0]
env['res.company'].browse(1).write({'country_id': country_code and country.id, 'currency_id': country_code and country.currency_id.id})
+ if len(country_timezones.get(country_code, [])) == 1:
+ users = env['res.users'].search([])
+ users.write({'tz': country_timezones[country_code][0]})
if phone:
env['res.company'].browse(1).write({'phone': phone})
if '@' in login:
diff --git a/odoo/tests/common.py b/odoo/tests/common.py
index 39bb02ab717..6c562acff79 100644
--- a/odoo/tests/common.py
+++ b/odoo/tests/common.py
@@ -1097,42 +1097,6 @@ class Form(object):
arch = etree.fromstring(fvg['arch'])
object.__setattr__(self, '_view', fvg)
- # TODO: make this less crappy?
- # look up edition view for the O2M
- for f, descr in fvg['fields'].items():
- if descr['type'] != 'one2many':
- continue
-
- node = next(n for n in arch.iter('field') if n.get('name') == f)
- default_view = next(
- (m for m in node.get('mode', 'tree').split(',') if m != 'form'),
- 'tree'
- )
-
- refs = {
- m.group('view_type'): m.group('view_id')
- for m in ref_re.finditer(node.get('context', ''))
- }
- # always fetch for simplicity, ensure we always have a tree and
- # a form view
- submodel = env[descr['relation']]
- views = submodel.with_context(**refs) \
- .load_views([(False, 'tree'), (False, 'form')])['fields_views']
- # embedded views should take the priority on externals
- views.update(descr['views'])
- # re-set all resolved views on the descriptor
- descr['views'] = views
-
- # if the default view is a kanban or a non-editable list, the
- # "edition controller" is the form view
- edition = views['form']
- if default_view == 'tree':
- subarch = etree.fromstring(views['tree']['arch'])
- if subarch.get('editable'):
- edition = views['tree']
-
- self._process_fvg(submodel, edition)
- descr['views']['edition'] = edition
self._process_fvg(recordp, fvg)
@@ -1149,6 +1113,35 @@ class Form(object):
else:
self._init_from_defaults(self._model)
+ def _o2m_set_edition_view(self, descr, node):
+ default_view = next(
+ (m for m in node.get('mode', 'tree').split(',') if m != 'form'),
+ 'tree'
+ )
+ refs = {
+ m.group('view_type'): m.group('view_id')
+ for m in ref_re.finditer(node.get('context', ''))
+ }
+ # always fetch for simplicity, ensure we always have a tree and
+ # a form view
+ submodel = self._env[descr['relation']]
+ views = submodel.with_context(**refs) \
+ .load_views([(False, 'tree'), (False, 'form')])['fields_views']
+ # embedded views should take the priority on externals
+ views.update(descr['views'])
+ # re-set all resolved views on the descriptor
+ descr['views'] = views
+ # if the default view is a kanban or a non-editable list, the
+ # "edition controller" is the form view
+ edition = views['form']
+ if default_view == 'tree':
+ subarch = etree.fromstring(views['tree']['arch'])
+ if subarch.get('editable'):
+ edition = views['tree']
+
+ self._process_fvg(submodel, edition)
+ descr['views']['edition'] = edition
+
def __str__(self):
return "<%s %s(%s)>" % (
type(self).__name__,
@@ -1167,7 +1160,7 @@ class Form(object):
# pre-resolve modifiers & bind to arch toplevel
modifiers = fvg['modifiers'] = {}
contexts = fvg['contexts'] = {}
- for f in etree.fromstring(fvg['arch']).iter('field'):
+ for f in etree.fromstring(fvg['arch']).xpath('//field[not(ancestor::field)]'):
fname = f.get('name')
modifiers[fname] = {
modifier: domain if isinstance(domain, bool) else normalize_domain(domain)
@@ -1176,6 +1169,11 @@ class Form(object):
ctx = f.get('context')
if ctx:
contexts[fname] = ctx
+
+ descr = fvg['fields'].get(fname) or {'type': None}
+ if descr['type'] == 'one2many':
+ self._o2m_set_edition_view(descr, f)
+
fvg['modifiers']['id'] = {'required': False, 'readonly': True}
fvg['onchange'] = model._onchange_spec(fvg)
diff --git a/odoo/tools/__init__.py b/odoo/tools/__init__.py
index 6e89bdd9d43..2381f8c3472 100644
--- a/odoo/tools/__init__.py
+++ b/odoo/tools/__init__.py
@@ -7,7 +7,6 @@ from . import appdirs
from . import pdf
from .config import config
from .misc import *
-from .convert import *
from .translate import *
from .graph import graph
from .image import *
@@ -18,3 +17,4 @@ from .func import *
from .debugger import *
from .xml_utils import *
from .date_utils import *
+from .convert import *
diff --git a/odoo/tools/convert.py b/odoo/tools/convert.py
index b98e586a62e..2e984a39618 100644
--- a/odoo/tools/convert.py
+++ b/odoo/tools/convert.py
@@ -19,7 +19,7 @@ from . import assertion_report, pycompat
from .config import config
from .misc import file_open, unquote, ustr, SKIPPED_ELEMENT_TYPES
from .translate import _
-from odoo import SUPERUSER_ID
+from odoo import SUPERUSER_ID, api
_logger = logging.getLogger(__name__)
@@ -177,20 +177,32 @@ def _eval_xml(self, node, env):
return tuple(res)
return res
elif node.tag == "function":
- args = []
a_eval = node.get('eval')
- # FIXME: should probably be exclusive
if a_eval:
self.idref['ref'] = self.id_get
- args = safe_eval(a_eval, self.idref)
- for n in node:
- return_val = _eval_xml(self, n, env)
- if return_val is not None:
- args.append(return_val)
+ # ensure the args are a list (sometimes folks eval a tuple which
+ # is inconvenient when trying to concatenate w/ a list)
+ args = list(safe_eval(a_eval, self.idref))
+ else:
+ args = [
+ r for r in (_eval_xml(self, n, env) for n in node)
+ if r is not None
+ ]
+
model = env[node.get('model')]
- method = node.get('name')
- # this one still depends on the old API
- return odoo.api.call_kw(model, method, args, {})
+ method_name = node.get('name')
+ method = getattr(model, method_name)
+
+ # this mess is necessary to merge @context and a possible positional
+ # context parameter, as <function> works on the old API so ids and
+ # context are just args
+ ids, args = [], list(args)
+ if getattr(method, '_api', None) not in ('model', 'model_create'):
+ ids, args = args[:1], args[1:]
+ context, args, kwargs = api.split_context(method, args, {})
+ kwargs['context'] = {**env.context, **(context or {})}
+
+ return odoo.api.call_kw(model, method_name, ids + args, kwargs)
elif node.tag == "test":
return node.text
@@ -198,48 +210,30 @@ def _eval_xml(self, node, env):
def str2bool(value):
return value.lower() not in ('0', 'false', 'off')
+def nodeattr2bool(node, attr, default=False):
+ if not node.get(attr):
+ return default
+ val = node.get(attr).strip()
+ if not val:
+ return default
+ return str2bool(val)
class xml_import(object):
-
- @staticmethod
- def nodeattr2bool(node, attr, default=False):
- if not node.get(attr):
- return default
- val = node.get(attr).strip()
- if not val:
- return default
- return str2bool(val)
-
- def isnoupdate(self, data_node=None):
- return self.noupdate or (len(data_node) and self.nodeattr2bool(data_node, 'noupdate', False))
-
- def get_context(self, data_node, node, eval_dict):
- data_node_context = (len(data_node) and data_node.get('context',''))
- node_context = node.get("context")
- context = {}
- for ctx in (data_node_context, node_context):
- if ctx:
- try:
- ctx_res = safe_eval(ctx, eval_dict)
- if isinstance(context, dict):
- context.update(ctx_res)
- else:
- context = ctx_res
- except (ValueError, NameError):
- # Some contexts contain references that are only valid at runtime at
- # client-side, so in that case we keep the original context string
- # as it is. We also log it, just in case.
- context = ctx
- _logger.debug('Context value (%s) for element with id "%s" or its data node does not parse '\
- 'at server-side, keeping original string, in case it\'s meant for client side only',
- ctx, node.get('id','n/a'), exc_info=True)
- return context
-
- def get_uid(self, data_node, node):
- node_uid = node.get('uid','') or (len(data_node) and data_node.get('uid',''))
- if node_uid:
- return self.id_get(node_uid)
- return self.uid
+ def get_env(self, node, eval_context=None):
+ uid = node.get('uid')
+ context = node.get('context')
+ if uid or context:
+ return self.env(
+ user=uid and self.id_get(uid),
+ context=context and {
+ **self.env.context,
+ **safe_eval(context, {
+ 'ref': self.id_get,
+ **(eval_context or {})
+ })
+ }
+ )
+ return self.env
def make_xml_id(self, xml_id):
if not xml_id or '.' in xml_id:
@@ -247,7 +241,6 @@ class xml_import(object):
return "%s.%s" % (self.module, xml_id)
def _test_xml_id(self, xml_id):
- id = xml_id
if '.' in xml_id:
module, id = xml_id.split('.', 1)
assert '.' not in id, """The ID reference "%s" must contain
@@ -257,30 +250,30 @@ form: module.record_id""" % (xml_id,)
modcnt = self.env['ir.module.module'].search_count([('name', '=', module), ('state', '=', 'installed')])
assert modcnt == 1, """The ID "%s" refers to an uninstalled module""" % (xml_id,)
- def _tag_delete(self, rec, data_node=None, mode=None):
- d_model = rec.get("model")
- d_search = rec.get("search")
- d_id = rec.get("id")
+ def _tag_delete(self, rec, _data_node):
+ d_model = rec["model"]
records = self.env[d_model]
+ d_search = rec.get("search")
if d_search:
idref = _get_idref(self, self.env, d_model, {})
try:
records = records.search(safe_eval(d_search, idref))
except ValueError:
_logger.warning('Skipping deletion for failed search `%r`', d_search, exc_info=True)
- pass
+
+ d_id = rec.get("id")
if d_id:
try:
records += records.browse(self.id_get(d_id))
except ValueError:
# d_id cannot be found. doesn't matter in this case
_logger.warning('Skipping deletion for missing XML ID `%r`', d_id, exc_info=True)
- pass
+
if records:
records.unlink()
- def _tag_report(self, rec, data_node=None, mode=None):
+ def _tag_report(self, rec):
res = {}
for dest,f in (('name','string'),('model','model'),('report_name','name')):
res[dest] = rec.get(f)
@@ -322,7 +315,7 @@ form: module.record_id""" % (xml_id,)
res['paperformat_id'] = pf_id
xid = self.make_xml_id(xml_id)
- data = dict(xml_id=xid, values=res, noupdate=self.isnoupdate(data_node))
+ data = dict(xml_id=xid, values=res, noupdate=self.noupdate)
report = self.env['ir.actions.report']._load_records([data], self.mode == 'update')
self.idref[xml_id] = report.id
@@ -333,16 +326,13 @@ form: module.record_id""" % (xml_id,)
report.unlink_action()
return report.id
- def _tag_function(self, rec, data_node=None, mode=None):
- if self.isnoupdate(data_node) and self.mode != 'init':
+ def _tag_function(self, rec):
+ if self.noupdate and self.mode != 'init':
return
- context = self.get_context(data_node, rec, {'ref': self.id_get})
- uid = self.get_uid(data_node, rec)
- env = self.env(user=uid, context=context)
+ env = self.get_env(rec)
_eval_xml(self, rec, env)
- return
- def _tag_act_window(self, rec, data_node=None, mode=None):
+ def _tag_act_window(self, rec):
name = rec.get('name')
xml_id = rec.get('id','')
self._test_xml_id(xml_id)
@@ -357,7 +347,7 @@ form: module.record_id""" % (xml_id,)
view_mode = rec.get('view_mode') or 'tree,form'
usage = rec.get('usage')
limit = rec.get('limit')
- uid = self.uid
+ uid = self.env.user.id
# Act_window's 'domain' and 'context' contain mostly literals
# but they can also refer to the variables provided below
@@ -390,9 +380,8 @@ form: module.record_id""" % (xml_id,)
'active_id': active_id,
'active_ids': active_ids,
'active_model': active_model,
- 'ref': self.id_get,
}
- context = self.get_context(data_node, rec, eval_context)
+ context = self.get_env(rec, eval_context).context
try:
domain = safe_eval(domain, eval_context)
@@ -441,144 +430,79 @@ form: module.record_id""" % (xml_id,)
res['binding_type'] = 'action_form_only'
xid = self.make_xml_id(xml_id)
- data = dict(xml_id=xid, values=res, noupdate=self.isnoupdate(data_node))
+ data = dict(xml_id=xid, values=res, noupdate=self.noupdate)
self.env['ir.actions.act_window']._load_records([data], self.mode == 'update')
- def _tag_menuitem(self, rec, data_node=None, mode=None):
- rec_id = rec.get("id")
+ def _tag_menuitem(self, rec):
+ rec_id = rec.attrib["id"]
self._test_xml_id(rec_id)
# The parent attribute was specified, if non-empty determine its ID, otherwise
# explicitly make a top-level menu
+ values = {
+ 'parent_id': False,
+ 'active': nodeattr2bool(rec, 'active', default=True),
+ }
+
+ if rec.get('sequence'):
+ values['sequence'] = int(rec.get('sequence'))
+
if rec.get('parent'):
- menu_parent_id = self.id_get(rec.get('parent',''))
+ values['parent_id'] = self.id_get(rec.attrib['parent'])
else:
- # we get here with <menuitem parent="">, explicit clear of parent, or
- # if no parent attribute at all but menu name is not a menu path
- menu_parent_id = False
- values = {'parent_id': menu_parent_id}
+ if rec.get('web_icon'):
+ values['web_icon'] = rec.attrib['web_icon']
+
+
if rec.get('name'):
- values['name'] = rec.get('name')
- try:
- res = [ self.id_get(rec.get('id','')) ]
- except:
- res = None
+ values['name'] = rec.attrib['name']
if rec.get('action'):
- a_action = rec.get('action')
+ a_action = rec.attrib['action']
- # determine the type of action
- action_model, action_id = self.model_id_get(a_action)
- action_type = action_model.split('.')[-1] # keep only type part
- values['action'] = "ir.actions.%s,%d" % (action_type, action_id)
+ if '.' not in a_action:
+ a_action = '%s.%s' % (self.module, a_action)
+ act = self.env.ref(a_action).sudo()
+ values['action'] = "%s,%d" % (act.type, act.id)
- if not values.get('name') and action_type in ('act_window', 'wizard', 'url', 'client', 'server'):
- resw = self.env[action_model].sudo().browse(action_id).name
- if resw:
- values['name'] = resw
+ if not values.get('name') and act.type.endswith(('act_window', 'wizard', 'url', 'client', 'server')) and act.name:
+ values['name'] = act.name
if not values.get('name'):
- # ensure menu has a name
values['name'] = rec_id or '?'
- if rec.get('sequence'):
- values['sequence'] = int(rec.get('sequence'))
-
- values['active'] = self.nodeattr2bool(rec, 'active', default=True)
- if rec.get('groups'):
- g_names = rec.get('groups','').split(',')
- groups_value = []
- for group in g_names:
- if group.startswith('-'):
- group_id = self.id_get(group[1:])
- groups_value.append((3, group_id))
- else:
- group_id = self.id_get(group)
- groups_value.append((4, group_id))
- values['groups_id'] = groups_value
+ groups = []
+ for group in rec.get('groups', '').split(','):
+ if group.startswith('-'):
+ group_id = self.id_get(group[1:])
+ groups.append((3, group_id))
+ elif group:
+ group_id = self.id_get(group)
+ groups.append((4, group_id))
+ if groups:
+ values['groups_id'] = groups
- if not values.get('parent_id'):
- if rec.get('web_icon'):
- values['web_icon'] = rec.get('web_icon')
- xid = self.make_xml_id(rec_id)
- data = dict(xml_id=xid, values=values, noupdate=self.isnoupdate(data_node))
+ data = {
+ 'xml_id': self.make_xml_id(rec_id),
+ 'values': values,
+ 'noupdate': self.noupdate,
+ }
self.env['ir.ui.menu']._load_records([data], self.mode == 'update')
- def _assert_equals(self, f1, f2, prec=4):
- return not round(f1 - f2, prec)
-
- def _tag_assert(self, rec, data_node=None, mode=None):
- if self.isnoupdate(data_node) and self.mode != 'init':
- return
-
- rec_model = rec.get("model")
- rec_id = rec.get("id")
- self._test_xml_id(rec_id)
- rec_src = rec.get("search")
- rec_src_count = rec.get("count")
-
- rec_string = rec.get("string") or 'unknown'
-
- records = None
- eval_dict = {'ref': self.id_get}
- context = self.get_context(data_node, rec, eval_dict)
- uid = self.get_uid(data_node, rec)
- env = self.env(user=uid, context=context)
- if rec_id:
- records = env[rec_model].browse(self.id_get(rec_id))
- elif rec_src:
- q = safe_eval(rec_src, eval_dict)
- records = env[rec_model].search(q)
- if rec_src_count:
- count = int(rec_src_count)
- if len(records) != count:
- self.assertion_report.record_failure()
- msg = 'assertion "%s" failed!\n' \
- ' Incorrect search count:\n' \
- ' expected count: %d\n' \
- ' obtained count: %d\n' \
- % (rec_string, count, len(records))
- _logger.error(msg)
- return
-
- assert records is not None,\
- 'You must give either an id or a search criteria'
- ref = self.id_get
- for record in records:
- globals_dict = RecordDictWrapper(record)
- globals_dict['floatEqual'] = self._assert_equals
- globals_dict['ref'] = ref
- globals_dict['_ref'] = ref
- for test in rec.findall('./test'):
- f_expr = test.get("expr",'')
- env = self.env(user=uid, context=context)
- expected_value = _eval_xml(self, test, env) or True
- expression_value = safe_eval(f_expr, globals_dict)
- if expression_value != expected_value: # assertion failed
- self.assertion_report.record_failure()
- msg = 'assertion "%s" failed!\n' \
- ' xmltag: %s\n' \
- ' expected value: %r\n' \
- ' obtained value: %r\n' \
- % (rec_string, etree.tostring(test, encoding='unicode'), expected_value, expression_value)
- _logger.error(msg)
- return
- else: # all tests were successful for this assertion tag (no break)
- self.assertion_report.record_success()
-
- def _tag_record(self, rec, data_node=None, mode=None):
+ def _tag_record(self, rec):
rec_model = rec.get("model")
- model = self.env[rec_model]
+ env = self.get_env(rec)
rec_id = rec.get("id", '')
- rec_context = rec.get("context", {})
- if rec_context:
- rec_context = safe_eval(rec_context)
+
+ model = env[rec_model]
if self.xml_filename and rec_id:
- rec_context['install_module'] = self.module
- rec_context['install_filename'] = self.xml_filename
+ model = model.with_context(
+ install_module=self.module,
+ install_filename=self.xml_filename
+ )
self._test_xml_id(rec_id)
xid = self.make_xml_id(rec_id)
@@ -586,18 +510,18 @@ form: module.record_id""" % (xml_id,)
# in update mode, the record won't be updated if the data node explicitly
# opt-out using @noupdate="1". A second check will be performed in
# model._load_records() using the record's ir.model.data `noupdate` field.
- if self.isnoupdate(data_node) and self.mode != 'init':
+ if self.noupdate and self.mode != 'init':
# check if the xml record has no id, skip
if not rec_id:
return None
- record = self.env['ir.model.data']._load_xmlid(xid)
+ record = env['ir.model.data']._load_xmlid(xid)
if record:
# if the resource already exists, don't update it but store
# its database id (can be useful)
self.idref[rec_id] = record.id
return None
- elif not self.nodeattr2bool(rec, 'forcecreate', True):
+ elif not nodeattr2bool(rec, 'forcecreate', True):
# if it doesn't exist and we shouldn't create it, skip it
return None
# else create it normally
@@ -606,7 +530,7 @@ form: module.record_id""" % (xml_id,)
# updating a record created by another module
record = self.env['ir.model.data']._load_xmlid(xid)
if not record:
- if self.isnoupdate(data_node) and not self.nodeattr2bool(rec, 'forcecreate', True):
+ if self.noupdate and not nodeattr2bool(rec, 'forcecreate', True):
# if it doesn't exist and we shouldn't create it, skip it
return None
raise Exception("Cannot update missing record %r" % xid)
@@ -624,16 +548,13 @@ form: module.record_id""" % (xml_id,)
f_val = False
if f_search:
- context = self.get_context(data_node, rec, {'ref': self.id_get})
- uid = self.get_uid(data_node, rec)
- env = self.env(user=uid, context=context)
idref2 = _get_idref(self, env, f_model, self.idref)
q = safe_eval(f_search, idref2)
assert f_model, 'Define an attribute model="..." in your .XML file !'
# browse the objects searched
- s = self.env[f_model].search(q)
+ s = env[f_model].search(q)
# column definitions of the "local" object
- _fields = self.env[rec_model]._fields
+ _fields = env[rec_model]._fields
# if the current field is many2many
if (f_name in _fields) and _fields[f_name].type == 'many2many':
f_val = [(6, 0, [x[f_use] for x in s])]
@@ -648,7 +569,7 @@ form: module.record_id""" % (xml_id,)
else:
f_val = self.id_get(f_ref)
else:
- f_val = _eval_xml(self, field, self.env)
+ f_val = _eval_xml(self, field, env)
if f_name in model._fields:
if model._fields[f_name].type == 'integer':
f_val = int(f_val)
@@ -658,15 +579,15 @@ form: module.record_id""" % (xml_id,)
f_val = str2bool(f_val)
res[f_name] = f_val
- data = dict(xml_id=xid, values=res, noupdate=self.isnoupdate(data_node))
- record = model.with_context(rec_context)._load_records([data], self.mode == 'update')
+ data = dict(xml_id=xid, values=res, noupdate=self.noupdate)
+ record = model._load_records([data], self.mode == 'update')
if rec_id:
self.idref[rec_id] = record.id
if config.get('import_partial'):
- self.cr.commit()
+ env.cr.commit()
return rec_model, record.id
- def _tag_template(self, el, data_node=None, mode=None):
+ def _tag_template(self, el):
# This helper transforms a <template> element into a <record> and forwards it
tpl_id = el.get('id', el.get('t-name'))
full_tpl_id = tpl_id
@@ -710,7 +631,7 @@ form: module.record_id""" % (xml_id,)
record.append(Field(el.get('key'), name='key'))
if el.get('active') in ("True", "False"):
view_id = self.id_get(tpl_id, raise_if_not_found=False)
- if mode != "update" or not view_id:
+ if self.mode != "update" or not view_id:
record.append(Field(name='active', eval=el.get('active')))
if el.get('customize_show') in ("True", "False"):
record.append(Field(name='customize_show', eval=el.get('customize_show')))
@@ -732,7 +653,7 @@ form: module.record_id""" % (xml_id,)
# the ``arch`` field
record.append(Field(el, name="arch", type="xml"))
- return self._tag_record(record, data_node)
+ return self._tag_record(record)
def id_get(self, id_str, raise_if_not_found=True):
if id_str in self.idref:
@@ -745,36 +666,37 @@ form: module.record_id""" % (xml_id,)
id_str = '%s.%s' % (self.module, id_str)
return self.env['ir.model.data'].xmlid_to_res_model_res_id(id_str, raise_if_not_found=raise_if_not_found)
- def parse(self, de, mode=None):
- roots = ['openerp','data','odoo']
- if de.tag not in roots:
- raise Exception("Root xml tag must be <openerp>, <odoo> or <data>.")
- for rec in de:
- if rec.tag in roots:
- self.parse(rec, mode)
- elif rec.tag in self._tags:
- try:
- self._tags[rec.tag](rec, de, mode=mode)
- except Exception as e:
- exc_info = sys.exc_info()
- pycompat.reraise(
- ParseError,
- ParseError(ustr(e), etree.tostring(rec, encoding='unicode').rstrip(), rec.getroottree().docinfo.URL, rec.sourceline),
- exc_info[2]
- )
- return True
+ def _tag_root(self, el):
+ for rec in el:
+ f = self._tags.get(rec.tag)
+ if f is None:
+ continue
+
+ self.envs.append(self.get_env(el))
+ self._noupdate.append(nodeattr2bool(el, 'noupdate', self.noupdate))
+ try:
+ f(rec)
+ finally:
+ self._noupdate.pop()
+ self.envs.pop()
+
+ @property
+ def env(self):
+ return self.envs[-1]
+
+ @property
+ def noupdate(self):
+ return self._noupdate[-1]
def __init__(self, cr, module, idref, mode, report=None, noupdate=False, xml_filename=None):
self.mode = mode
self.module = module
- self.env = odoo.api.Environment(cr, SUPERUSER_ID, {})
- self.cr = cr
- self.uid = SUPERUSER_ID
- self.idref = idref
+ self.envs = [odoo.api.Environment(cr, SUPERUSER_ID, {})]
+ self.idref = {} if idref is None else idref
if report is None:
report = assertion_report.assertion_report()
self.assertion_report = report
- self.noupdate = noupdate
+ self._noupdate = [noupdate]
self.xml_filename = xml_filename
self._tags = {
'record': self._tag_record,
@@ -784,9 +706,26 @@ form: module.record_id""" % (xml_id,)
'template': self._tag_template,
'report': self._tag_report,
'act_window': self._tag_act_window,
- 'assert': self._tag_assert,
+
+ **dict.fromkeys(self.DATA_ROOTS, self._tag_root)
}
+ def parse(self, de):
+ assert de.tag in self.DATA_ROOTS, "Root xml tag must be <openerp>, <odoo> or <data>."
+ try:
+ self._tag_root(de)
+ except Exception as e:
+ exc_info = sys.exc_info()
+ pycompat.reraise(
+ ParseError,
+ ParseError(
+ ustr(e),
+ etree.tostring(de, encoding='unicode').rstrip(),
+ de.getroottree().docinfo.URL, de.sourceline),
+ exc_info[2]
+ )
+ DATA_ROOTS = ['odoo', 'data', 'openerp']
+
def convert_file(cr, module, filename, idref, mode='update', noupdate=False, kind=None, report=None, pathname=None):
if pathname is None:
pathname = os.path.join(module, filename)
@@ -854,12 +793,9 @@ def convert_xml_import(cr, module, xmlfile, idref=None, mode='init', noupdate=Fa
_logger.info(ustr(relaxng.error_log.last_error))
raise
- if idref is None:
- idref={}
if isinstance(xmlfile, pycompat.string_types):
xml_filename = xmlfile
else:
xml_filename = xmlfile.name
obj = xml_import(cr, module, idref, mode, report=report, noupdate=noupdate, xml_filename=xml_filename)
- obj.parse(doc.getroot(), mode=mode)
- return True
+ obj.parse(doc.getroot())
diff --git a/setup.py b/setup.py
index ab2b730b50e..f9c36c6222c 100644
--- a/setup.py
+++ b/setup.py
@@ -40,7 +40,6 @@ setup(
'psutil', # windows binary code.google.com/p/psutil/downloads/list
'psycopg2 >= 2.2',
'pydot',
- 'pyldap', # optional
'pyparsing',
'pypdf2',
'pyserial',
@@ -59,6 +58,7 @@ setup(
],
python_requires='>=3.5',
extras_require={
+ 'ldap': ['pyldap'],
'SSL': ['pyopenssl'],
},
tests_require=[