summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristophe Simonis <chs@odoo.com>2019-03-13 11:45:01 +0000
committerChristophe Simonis <chs@odoo.com>2019-03-13 14:24:51 +0000
commit44515bc7beae3b4c46b5bc2959c320695b51439d (patch)
tree5f56871242f7b5bc8a323c51f2749411e555159e
parentbf69975b54164a21bfe00b2359d972329516a34e (diff)
parentc9f832d9f032b6af51807b387b325b5c2b8aad00 (diff)
[MERGE] forward port branch saas-12.2 up to c9f832d9f03
closes odoo/odoo#31790 Signed-off-by: Christophe Simonis <chs@odoo.com>
-rw-r--r--addons/account/models/account_bank_statement.py5
-rw-r--r--addons/account/models/account_invoice.py12
-rw-r--r--addons/account/models/account_journal_dashboard.py2
-rw-r--r--addons/account/models/account_payment.py10
-rw-r--r--addons/account/models/account_reconcile_model.py2
-rw-r--r--addons/account/models/reconciliation_widget.py22
-rw-r--r--addons/account/tests/test_reconciliation_widget.py27
-rw-r--r--addons/account/views/account_move_views.xml4
-rw-r--r--addons/account/views/account_payment_view.xml2
-rw-r--r--addons/account/views/report_invoice.xml2
-rw-r--r--addons/analytic/models/analytic_account.py5
-rw-r--r--addons/auth_signup/models/res_partner.py8
-rw-r--r--addons/base_automation/data/base_automation_demo.xml15
-rw-r--r--addons/base_automation/models/base_automation.py19
-rw-r--r--addons/base_automation/tests/test_base_automation.py59
-rw-r--r--addons/base_import/static/csv/o2m_customers_contacts.csv16
-rw-r--r--addons/crm/models/crm_team.py5
-rw-r--r--addons/event_sale/models/sale_order.py5
-rw-r--r--addons/gamification/data/gamification_karma_rank_data.xml91
-rw-r--r--addons/gamification/data/gamification_karma_rank_demo.xml52
-rw-r--r--addons/gamification/models/badge.py2
-rw-r--r--addons/gamification/models/res_users.py20
-rw-r--r--addons/gamification/static/img/rank_misc_world.pngbin0 -> 53474 bytes
-rw-r--r--addons/hr/models/hr.py8
-rw-r--r--addons/hr_fleet/controllers/i18n/az.po115
-rw-r--r--addons/hr_fleet/controllers/i18n/fil.po115
-rw-r--r--addons/hr_gamification/wizard/gamification_badge_user_wizard.py2
-rw-r--r--addons/hr_holidays/models/hr_leave.py3
-rw-r--r--addons/hr_holidays/models/hr_leave_allocation.py2
-rw-r--r--addons/hr_holidays/report/hr_leave_reports.xml2
-rw-r--r--addons/hr_holidays/security/hr_holidays_security.xml13
-rw-r--r--addons/hr_holidays/tests/common.py3
-rw-r--r--addons/hr_holidays/tests/test_access_rights.py31
-rw-r--r--addons/hr_holidays/tests/test_leave_requests.py2
-rw-r--r--addons/hr_payroll/models/hr_benefit.py8
-rw-r--r--addons/hr_payroll/tests/test_benefit.py32
-rw-r--r--addons/hr_skills/static/src/js/resume_widget.js6
-rw-r--r--addons/l10n_be_hr_payroll/views/res_config_settings_views.xml20
-rw-r--r--addons/l10n_fr_fec/wizard/account_fr_fec.py79
-rw-r--r--addons/l10n_multilang/models/l10n_multilang.py15
-rw-r--r--addons/lunch/static/src/scss/lunch_kanban.scss8
-rw-r--r--addons/lunch/views/lunch_orders_views.xml2
-rw-r--r--addons/mail/models/ir_actions.py25
-rw-r--r--addons/mail/models/mail_activity.py20
-rw-r--r--addons/mail/models/mail_tracking_value.py4
-rw-r--r--addons/mail/static/src/js/composers/mention_manager.js9
-rw-r--r--addons/mail/static/src/js/thread_widget.js2
-rw-r--r--addons/mail/static/tests/discuss_tests.js71
-rw-r--r--addons/mass_mailing/models/mass_mailing.py53
-rw-r--r--addons/mass_mailing/static/src/js/mass_mailing_widget.js3
-rw-r--r--addons/mass_mailing/tests/test_mass_mailing_shortener.py1
-rw-r--r--addons/mass_mailing/views/unsubscribe_templates.xml2
-rw-r--r--addons/mrp/models/mrp_abstract_workorder.py7
-rw-r--r--addons/mrp/models/mrp_bom.py4
-rw-r--r--addons/mrp/models/mrp_production.py7
-rw-r--r--addons/mrp/models/mrp_workorder.py7
-rw-r--r--addons/mrp/models/stock_move.py20
-rw-r--r--addons/mrp/models/stock_rule.py1
-rw-r--r--addons/mrp/models/stock_warehouse.py16
-rw-r--r--addons/mrp/tests/test_order.py26
-rw-r--r--addons/mrp/views/mrp_unbuild_views.xml2
-rw-r--r--addons/mrp/views/stock_move_views.xml3
-rw-r--r--addons/payment_authorize/models/payment.py15
-rw-r--r--addons/payment_authorize/tests/test_authorize.py15
-rw-r--r--addons/payment_payulatam/models/payment.py2
-rw-r--r--addons/payment_payulatam/tests/test_payulatam.py3
-rw-r--r--addons/point_of_sale/static/src/css/pos.css2
-rw-r--r--addons/point_of_sale/static/src/js/screens.js2
-rw-r--r--addons/point_of_sale/views/pos_order_view.xml2
-rw-r--r--addons/point_of_sale/views/pos_session_view.xml2
-rw-r--r--addons/portal/controllers/portal.py8
-rw-r--r--addons/portal/wizard/portal_share.py2
-rw-r--r--addons/portal/wizard/portal_wizard.py2
-rw-r--r--addons/product/models/product.py15
-rw-r--r--addons/product/models/product_attribute.py26
-rw-r--r--addons/product/models/product_pricelist.py14
-rw-r--r--addons/product/models/product_template.py293
-rw-r--r--addons/product/tests/test_product_attribute_value_config.py91
-rw-r--r--addons/product/views/product_views.xml3
-rw-r--r--addons/purchase_requisition/models/purchase_requisition.py2
-rw-r--r--addons/purchase_requisition/views/purchase_requisition_views.xml4
-rw-r--r--addons/purchase_stock/models/purchase.py7
-rw-r--r--addons/repair/views/repair_views.xml2
-rw-r--r--addons/sale/controllers/product_configurator.py5
-rw-r--r--addons/sale/data/mail_data.xml2
-rw-r--r--addons/sale/models/payment.py2
-rw-r--r--addons/sale/models/product_template.py22
-rw-r--r--addons/sale/models/res_config_settings.py2
-rw-r--r--addons/sale/models/res_partner.py22
-rw-r--r--addons/sale/models/sale.py28
-rw-r--r--addons/sale/report/sale_report_templates.xml4
-rw-r--r--addons/sale/static/src/js/product_configurator_mixin.js8
-rw-r--r--addons/sale/static/src/js/product_configurator_modal.js12
-rw-r--r--addons/sale/tests/test_sale_product_attribute_value_config.py12
-rw-r--r--addons/sale_management/models/sale_order.py1
-rw-r--r--addons/sale_management/models/sale_order_template.py5
-rw-r--r--addons/sale_management/static/tests/product_configurator_advanced_test_ui.js21
-rw-r--r--addons/sale_management/views/sale_order_views.xml53
-rw-r--r--addons/stock/models/stock_inventory.py2
-rw-r--r--addons/stock/models/stock_location.py6
-rw-r--r--addons/stock/models/stock_move.py3
-rw-r--r--addons/stock/models/stock_picking.py2
-rw-r--r--addons/stock/models/stock_scrap.py3
-rw-r--r--addons/stock/models/stock_warehouse.py37
-rw-r--r--addons/stock/tests/test_move.py9
-rw-r--r--addons/stock/tests/test_move2.py7
-rw-r--r--addons/stock/views/stock_inventory_views.xml2
-rw-r--r--addons/stock/views/stock_move_views.xml4
-rw-r--r--addons/stock_account/views/product_views.xml2
-rw-r--r--addons/stock_account/views/stock_account_views.xml8
-rw-r--r--addons/stock_picking_batch/views/stock_picking_batch_views.xml2
-rw-r--r--addons/survey/data/mail_template_data.xml12
-rw-r--r--addons/survey/models/survey_survey.py31
-rw-r--r--addons/survey/models/survey_user.py3
-rw-r--r--addons/survey/static/src/js/tour_test_certification_failure.js4
-rw-r--r--addons/survey/static/src/js/tour_test_certification_success.js4
-rw-r--r--addons/survey/views/survey_survey_views.xml11
-rw-r--r--addons/survey/views/survey_templates.xml7
-rw-r--r--addons/survey/views/survey_user_views.xml14
-rw-r--r--addons/survey/wizard/survey_invite_views.xml2
-rw-r--r--addons/test_mail/tests/test_message_track.py6
-rw-r--r--addons/test_mass_mailing/tests/test_composer.py1
-rw-r--r--addons/test_mass_mailing/tests/test_link.py2
-rw-r--r--addons/test_mass_mailing/tests/test_mail_auto_blacklist.py1
-rw-r--r--addons/test_mass_mailing/tests/test_mass_mailing.py6
-rw-r--r--addons/test_mass_mailing/tests/test_performance.py2
-rw-r--r--addons/test_website/data/test_website_data.xml8
-rw-r--r--addons/test_website/static/src/js/reset_views.test.js4
-rw-r--r--addons/test_website/tests/__init__.py1
-rw-r--r--addons/test_website/tests/test_reset_views.py2
-rw-r--r--addons/test_website/tests/test_views_during_module_operation.py72
-rw-r--r--addons/web/static/src/js/chrome/action_manager_act_window.js3
-rw-r--r--addons/web/static/src/js/fields/basic_fields.js47
-rw-r--r--addons/web/static/src/js/fields/field_registry.js3
-rw-r--r--addons/web/static/src/js/fields/relational_fields.js12
-rw-r--r--addons/web/static/src/js/views/abstract_controller.js7
-rw-r--r--addons/web/static/src/js/views/control_panel/control_panel_controller.js10
-rw-r--r--addons/web/static/src/js/views/control_panel/search/search_bar.js10
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_renderer.js1
-rw-r--r--addons/web/static/src/js/views/list/list_editable_renderer.js4
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_model.js1
-rw-r--r--addons/web/static/src/js/widgets/auto_complete.js6
-rw-r--r--addons/web/static/src/scss/fields.scss5
-rw-r--r--addons/web/static/src/scss/form_view.scss10
-rw-r--r--addons/web/static/src/scss/list_view.scss1
-rw-r--r--addons/web/static/src/xml/base.xml4
-rw-r--r--addons/web/static/tests/chrome/action_manager_tests.js19
-rw-r--r--addons/web/static/tests/fields/basic_fields_tests.js86
-rw-r--r--addons/web/static/tests/fields/relational_fields_tests.js24
-rw-r--r--addons/web/static/tests/views/kanban_tests.js113
-rw-r--r--addons/web/static/tests/views/list_tests.js46
-rw-r--r--addons/web/static/tests/views/pivot_tests.js42
-rw-r--r--addons/web/static/tests/views/search_view_tests.js35
-rw-r--r--addons/web_editor/models/ir_ui_view.py3
-rw-r--r--addons/web_editor/static/src/js/backend/field_html.js4
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/plugin/media.js9
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/media_dialog.js6
-rw-r--r--addons/web_editor/static/src/js/wysiwyg_snippets/snippets.editor.js18
-rw-r--r--addons/web_editor/static/src/js/wysiwyg_snippets/snippets.options.js8
-rw-r--r--addons/web_editor/static/tests/field_html_tests.js20
-rw-r--r--addons/web_unsplash/models/__init__.py1
-rw-r--r--addons/web_unsplash/models/ir_qweb.py26
-rw-r--r--addons/website/__init__.py16
-rw-r--r--addons/website/__manifest__.py1
-rw-r--r--addons/website/models/ir_http.py4
-rw-r--r--addons/website/models/ir_ui_view.py7
-rw-r--r--addons/website/static/src/js/tours/rte.js20
-rw-r--r--addons/website/static/src/scss/website.edit_mode.scss3
-rw-r--r--addons/website/tests/test_page.py3
-rw-r--r--addons/website/tests/test_views.py3
-rw-r--r--addons/website_blog/views/website_blog_templates.xml3
-rw-r--r--addons/website_event_sale/models/__init__.py1
-rw-r--r--addons/website_event_sale/models/product_pricelist.py23
-rw-r--r--addons/website_forum/static/src/scss/website_forum.scss5
-rw-r--r--addons/website_forum/views/website_forum_profile.xml24
-rw-r--r--addons/website_links/static/src/js/website_links.js2
-rw-r--r--addons/website_profile/controllers/main.py1
-rw-r--r--addons/website_profile/static/src/img/badge_bronze.svg1
-rw-r--r--addons/website_profile/static/src/img/badge_gold.svg1
-rw-r--r--addons/website_profile/static/src/img/badge_silver.svg1
-rw-r--r--addons/website_profile/static/src/img/rank_1.svg1
-rw-r--r--addons/website_profile/static/src/img/rank_2.svg1
-rw-r--r--addons/website_profile/static/src/img/rank_3.svg1
-rw-r--r--addons/website_profile/static/src/scss/website_profile.scss409
-rw-r--r--addons/website_profile/views/website_profile.xml478
-rw-r--r--addons/website_sale/controllers/main.py2
-rw-r--r--addons/website_sale/models/product.py31
-rw-r--r--addons/website_sale/models/website.py2
-rw-r--r--addons/website_sale/static/src/js/website_sale.js16
-rw-r--r--addons/website_sale/static/src/js/website_sale_tour_shop_custom_attribute_value.js20
-rw-r--r--addons/website_sale/static/src/js/website_sale_tour_shop_customize.js10
-rw-r--r--addons/website_sale/static/src/js/website_sale_tour_shop_deleted_archived_variants.js10
-rw-r--r--addons/website_sale/static/src/js/website_sale_tour_shop_dynamic_variants.js1
-rw-r--r--addons/website_sale/static/src/js/website_sale_tour_shop_list_view_b2c.js96
-rw-r--r--addons/website_sale/static/src/js/website_sale_tour_shop_no_variant_attribute.js6
-rw-r--r--addons/website_sale/tests/test_customize.py20
-rw-r--r--addons/website_sale/tests/test_website_sale_pricelist.py9
-rw-r--r--addons/website_sale/tests/test_website_sale_product_attribute_value_config.py2
-rw-r--r--addons/website_sale/views/templates.xml29
-rw-r--r--addons/website_sale_comparison/views/website_sale_comparison_template.xml6
-rw-r--r--addons/website_sale_slides/data/website_sale_slides_demo.xml92
-rw-r--r--addons/website_sale_slides/static/img/5WMqwTnZ-qs.pngbin196182 -> 0 bytes
-rw-r--r--addons/website_sale_slides/static/img/ptjeDDoURL8.pngbin210254 -> 0 bytes
-rw-r--r--addons/website_sale_stock/controllers/main.py6
-rw-r--r--addons/website_sale_stock/models/product_template.py9
-rw-r--r--addons/website_sale_wishlist/views/website_sale_wishlist_template.xml4
-rw-r--r--addons/website_slides/controllers/main.py486
-rw-r--r--addons/website_slides/data/slide_channel_demo.xml101
-rw-r--r--addons/website_slides/data/slide_slide_demo.xml244
-rw-r--r--addons/website_slides/data/slide_user_demo.xml105
-rw-r--r--addons/website_slides/models/slide_channel.py39
-rw-r--r--addons/website_slides/models/slide_slide.py109
-rw-r--r--addons/website_slides/static/src/img/banner_heroes_default.svg1
-rw-r--r--addons/website_slides/static/src/img/channel_demo_diy.jpg (renamed from addons/website_sale_slides/static/img/channel_demo_diy.jpg)bin81965 -> 81965 bytes
-rw-r--r--addons/website_slides/static/src/img/channel_demo_furniture_3.jpg (renamed from addons/website_slides/static/src/img/channel_demo_furniture_3.jpeg)bin89623 -> 89623 bytes
-rw-r--r--addons/website_slides/static/src/img/slide_demo_gardening_1.jpgbin0 -> 98694 bytes
-rw-r--r--addons/website_slides/static/src/img/slide_demo_owl.jpgbin0 -> 37364 bytes
-rw-r--r--addons/website_slides/static/src/img/slide_demo_thumb_ebBez6bcSEc.jpgbin0 -> 26232 bytes
-rw-r--r--addons/website_slides/static/src/js/slides_course_fullscreen_player.js106
-rw-r--r--addons/website_slides/static/src/js/slides_course_quiz.js465
-rw-r--r--addons/website_slides/static/src/js/slides_course_slides_list.js14
-rw-r--r--addons/website_slides/static/src/js/slides_share.js3
-rw-r--r--addons/website_slides/static/src/scss/slide_slide.scss156
-rw-r--r--addons/website_slides/static/src/scss/slides_slide_fullscreen.scss41
-rw-r--r--addons/website_slides/static/src/scss/website_slides.scss166
-rw-r--r--addons/website_slides/static/src/scss/website_slides_profile.scss39
-rw-r--r--addons/website_slides/static/src/scss/website_slides_quiz.scss237
-rw-r--r--addons/website_slides/static/src/xml/slide_quiz.xml88
-rw-r--r--addons/website_slides/static/src/xml/website_slides_fullscreen.xml72
-rw-r--r--addons/website_slides/views/assets.xml2
-rw-r--r--addons/website_slides/views/website_slides_templates_course.xml183
-rw-r--r--addons/website_slides/views/website_slides_templates_homepage.xml152
-rw-r--r--addons/website_slides/views/website_slides_templates_lesson.xml825
-rw-r--r--addons/website_slides/views/website_slides_templates_lesson_fullscreen.xml9
-rw-r--r--addons/website_slides/views/website_slides_templates_profile.xml71
-rw-r--r--addons/website_slides_forum/__manifest__.py4
-rw-r--r--addons/website_slides_forum/controllers/main.py13
-rw-r--r--addons/website_slides_forum/data/slide_channel_demo.xml14
-rw-r--r--addons/website_slides_forum/static/src/xml/website_slides_forum_channel.xml32
-rw-r--r--addons/website_slides_forum/views/website_slides_forum_templates.xml6
-rw-r--r--addons/website_slides_survey/__manifest__.py3
-rw-r--r--addons/website_slides_survey/controllers/slides.py7
-rw-r--r--addons/website_slides_survey/data/slide_slide_demo.xml26
-rw-r--r--addons/website_slides_survey/models/slide_slide.py17
-rw-r--r--addons/website_slides_survey/static/src/xml/website_slides_fullscreen.xml7
-rw-r--r--addons/website_slides_survey/views/website_profile.xml14
-rw-r--r--addons/website_slides_survey/views/website_slides_templates_course.xml2
-rw-r--r--addons/website_slides_survey/views/website_slides_templates_lesson.xml (renamed from addons/website_slides_survey/views/website_slides_templates.xml)12
-rw-r--r--addons/website_slides_survey/views/website_slides_templates_lesson_fullscreen.xml11
-rw-r--r--addons/website_theme_install/tests/__init__.py1
-rw-r--r--addons/website_theme_install/tests/test_theme.py14
-rw-r--r--doc/_static/banners/email_gateway.jpgbin0 -> 83480 bytes
-rw-r--r--doc/_static/banners/email_gateway.small.jpgbin0 -> 28803 bytes
-rw-r--r--doc/cla/corporate/adhoc.md1
-rw-r--r--doc/cla/corporate/savoirfairelinux.md2
-rw-r--r--doc/cla/individual/mjabuz.md11
-rw-r--r--doc/howtos/backend/exercise-calendar9
-rw-r--r--doc/howtos/backend/exercise-gantt6
-rw-r--r--doc/howtos/backend/exercise-graph2
-rw-r--r--doc/howtos/backend/exercise-translations2
-rw-r--r--doc/setup.rst1
-rw-r--r--doc/setup/email_gateway.rst43
-rw-r--r--odoo/addons/base/models/ir_model.py5
-rw-r--r--odoo/addons/base/models/ir_rule.py2
-rw-r--r--odoo/addons/base/models/qweb.py101
-rw-r--r--odoo/addons/base/models/res_users.py34
-rw-r--r--odoo/addons/base/static/src/js/res_config_settings.js17
-rw-r--r--odoo/addons/base/static/tests/base_settings_tests.js36
-rw-r--r--odoo/addons/base/static/xls/res_partner.xlsbin33792 -> 7168 bytes
-rw-r--r--odoo/addons/base/tests/test_expression.py30
-rw-r--r--odoo/addons/base/tests/test_qweb.py41
-rw-r--r--odoo/addons/base/tests/test_user_has_group.py100
-rw-r--r--odoo/addons/base/wizard/base_import_language.py2
-rw-r--r--odoo/addons/test_new_api/tests/test_ui.py7
-rw-r--r--odoo/addons/test_testing_utilities/ir.model.access.csv2
-rw-r--r--odoo/addons/test_testing_utilities/menu.xml17
-rw-r--r--odoo/addons/test_testing_utilities/models.py22
-rw-r--r--odoo/addons/test_testing_utilities/tests/test_form_impl.py34
-rw-r--r--odoo/fields.py1
-rw-r--r--odoo/osv/expression.py2
-rw-r--r--odoo/tests/common.py10
281 files changed, 5110 insertions, 3416 deletions
diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py
index 2695db40ee6..553b6080cc6 100644
--- a/addons/account/models/account_bank_statement.py
+++ b/addons/account/models/account_bank_statement.py
@@ -637,9 +637,12 @@ class AccountBankStatementLine(models.Model):
# Create The payment
payment = self.env['account.payment']
+ partner_id = self.partner_id or (aml_dict.get('move_line') and aml_dict['move_line'].partner_id) or self.env['res.partner']
if abs(total)>0.00001:
payment_vals = self._prepare_payment_vals(total)
- if self.partner_id and len(account_types) == 1:
+ if not payment_vals['partner_id']:
+ payment_vals['partner_id'] = partner_id.id
+ if payment_vals['partner_id'] and len(account_types) == 1:
payment_vals['partner_type'] = 'customer' if account_types == receivable_account_type else 'supplier'
payment = payment.create(payment_vals)
diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py
index 897da38b719..4f221aba093 100644
--- a/addons/account/models/account_invoice.py
+++ b/addons/account/models/account_invoice.py
@@ -1621,12 +1621,14 @@ class AccountInvoice(models.Model):
fmt = partial(formatLang, invoice.with_context(lang=invoice.partner_id.lang).env, currency_obj=currency)
res = {}
for line in invoice.tax_line_ids:
- res.setdefault(line.tax_id.tax_group_id, {'base': 0.0, 'amount': 0.0})
- res[line.tax_id.tax_group_id]['amount'] += line.amount_total
- res[line.tax_id.tax_group_id]['base'] += line.base
- res = sorted(res.items(), key=lambda l: l[0].sequence)
+ tax = line.tax_id
+ group_key = (tax.tax_group_id, tax.amount_type, tax.amount)
+ res.setdefault(group_key, {'base': 0.0, 'amount': 0.0})
+ res[group_key]['amount'] += line.amount_total
+ res[group_key]['base'] += line.base
+ res = sorted(res.items(), key=lambda l: l[0][0].sequence)
invoice.amount_by_group = [(
- r[0].name, r[1]['amount'], r[1]['base'],
+ r[0][0].name, r[1]['amount'], r[1]['base'],
fmt(r[1]['amount']), fmt(r[1]['base']),
len(res),
) for r in res]
diff --git a/addons/account/models/account_journal_dashboard.py b/addons/account/models/account_journal_dashboard.py
index f815820da4a..afc9a6cec49 100644
--- a/addons/account/models/account_journal_dashboard.py
+++ b/addons/account/models/account_journal_dashboard.py
@@ -172,7 +172,7 @@ class account_journal(models.Model):
LEFT JOIN account_move move ON aml.move_id = move.id
WHERE aml.account_id in %%s
AND move.date <= %%s AND move.state = 'posted';""" % (amount_field,)
- self.env.cr.execute(query, (account_ids, fields.Date.today(),))
+ self.env.cr.execute(query, (account_ids, fields.Date.context_today(self),))
query_results = self.env.cr.dictfetchall()
if query_results and query_results[0].get('sum') != None:
account_sum = query_results[0].get('sum')
diff --git a/addons/account/models/account_payment.py b/addons/account/models/account_payment.py
index 7646343f7a4..dfc2b605790 100644
--- a/addons/account/models/account_payment.py
+++ b/addons/account/models/account_payment.py
@@ -236,10 +236,14 @@ class account_abstract_payment(models.AbstractModel):
currency = self.currency_id or self.journal_id.currency_id or self.journal_id.company_id.currency_id or invoices and invoices[0].currency_id
# Avoid currency rounding issues by summing the amounts according to the company_currency_id before
+ invoice_datas = invoices.read_group(
+ [('id', 'in', invoices.ids)],
+ ['currency_id', 'type', 'residual_signed'],
+ ['currency_id', 'type'], lazy=False)
total = 0.0
- groups = groupby(invoices, lambda i: i.currency_id)
- for payment_currency, payment_invoices in groups:
- amount_total = sum([MAP_INVOICE_TYPE_PAYMENT_SIGN[i.type] * i.residual_signed for i in payment_invoices])
+ for invoice_data in invoice_datas:
+ amount_total = MAP_INVOICE_TYPE_PAYMENT_SIGN[invoice_data['type']] * invoice_data['residual_signed']
+ payment_currency = self.env['res.currency'].browse(invoice_data['currency_id'][0])
if payment_currency == currency:
total += amount_total
else:
diff --git a/addons/account/models/account_reconcile_model.py b/addons/account/models/account_reconcile_model.py
index b3e7dbf6cc3..83aa46b8e0d 100644
--- a/addons/account/models/account_reconcile_model.py
+++ b/addons/account/models/account_reconcile_model.py
@@ -574,7 +574,7 @@ class AccountReconcileModel(models.Model):
if line_residual > total_residual:
amount_percentage = (total_residual / line_residual) * 100
else:
- amount_percentage = (line_residual / total_residual) * 100
+ amount_percentage = (line_residual / total_residual) * 100 if total_residual else 0
return amount_percentage >= self.match_total_amount_param
@api.multi
diff --git a/addons/account/models/reconciliation_widget.py b/addons/account/models/reconciliation_widget.py
index 8369d69e9b3..875a346dabd 100644
--- a/addons/account/models/reconciliation_widget.py
+++ b/addons/account/models/reconciliation_widget.py
@@ -143,11 +143,12 @@ class AccountReconciliation(models.AbstractModel):
else:
aml_ids = matching_amls[line.id]['aml_ids']
bank_statements_left += line.statement_id
+ target_currency = line.currency_id or line.journal_id.currency_id or line.journal_id.company_id.currency_id
amls = aml_ids and self.env['account.move.line'].browse(aml_ids)
line_vals = {
'st_line': self._get_statement_line(line),
- 'reconciliation_proposition': aml_ids and self._prepare_move_lines(amls) or [],
+ 'reconciliation_proposition': aml_ids and self._prepare_move_lines(amls, target_currency=target_currency, target_date=line.date) or [],
'model_id': matching_amls[line.id].get('model') and matching_amls[line.id]['model'].id,
'write_off': matching_amls[line.id].get('status') == 'write_off',
}
@@ -486,21 +487,12 @@ class AccountReconciliation(models.AbstractModel):
('payment_id', '<>', False)
]
- # Black lines = unreconciled & (not linked to a payment or open balance created by statement
- domain_matching = [('reconciled', '=', False)]
- if partner_id:
- domain_matching = expression.AND([
- domain_matching,
- [('account_id.internal_type', 'in', ['payable', 'receivable'])]
- ])
- else:
- # TODO : find out what use case this permits (match a check payment, registered on a journal whose account type is other instead of liquidity)
- domain_matching = expression.AND([
- domain_matching,
- [('account_id.reconcile', '=', True)]
- ])
+ # default domain matching
+ domain_matching = expression.AND([
+ [('reconciled', '=', False)],
+ [('account_id.reconcile', '=', True)]
+ ])
- # Let's add what applies to both
domain = expression.OR([domain_reconciliation, domain_matching])
if partner_id:
domain = expression.AND([domain, [('partner_id', '=', partner_id)]])
diff --git a/addons/account/tests/test_reconciliation_widget.py b/addons/account/tests/test_reconciliation_widget.py
index 7b5f21ee325..a24cec75ca5 100644
--- a/addons/account/tests/test_reconciliation_widget.py
+++ b/addons/account/tests/test_reconciliation_widget.py
@@ -2,6 +2,7 @@ import logging
import odoo.tests
import time
import requests
+from odoo.addons.account.tests.test_reconciliation import TestReconciliation
_logger = logging.getLogger(__name__)
@@ -27,3 +28,29 @@ class TestUi(odoo.tests.HttpCase):
self.phantom_js(prep.url.replace('http://localhost','').replace('?','#'),
"odoo.__DEBUG__.services['web_tour.tour'].run('bank_statement_reconciliation')",
"odoo.__DEBUG__.services['web_tour.tour'].tours.bank_statement_reconciliation.ready", login="admin")
+
+
+@odoo.tests.tagged('post_install', '-at_install')
+class TestReconciliationWidget(TestReconciliation):
+
+ def test_statement_suggestion_other_currency(self):
+ # company currency is EUR
+ # payment in USD
+ invoice = self.create_invoice(invoice_amount=50, currency_id=self.currency_usd_id)
+
+ # journal currency in USD
+ bank_stmt = self.acc_bank_stmt_model.create({
+ 'journal_id': self.bank_journal_usd.id,
+ 'date': time.strftime('%Y-07-15'),
+ 'name': 'payment %s' % invoice.number,
+ })
+
+ bank_stmt_line = self.acc_bank_stmt_line_model.create({'name': 'payment',
+ 'statement_id': bank_stmt.id,
+ 'partner_id': self.partner_agrolait_id,
+ 'amount': 50,
+ 'date': time.strftime('%Y-07-15'),
+ })
+
+ result = self.env['account.reconciliation.widget'].get_bank_statement_line_data(bank_stmt_line.ids)
+ self.assertEqual(result['lines'][0]['reconciliation_proposition'][0]['amount_str'], '$ 50.00') \ No newline at end of file
diff --git a/addons/account/views/account_move_views.xml b/addons/account/views/account_move_views.xml
index 6f59fe7320d..251fa4a8da5 100644
--- a/addons/account/views/account_move_views.xml
+++ b/addons/account/views/account_move_views.xml
@@ -276,7 +276,7 @@
</div>
<div class="col-6">
<span class="float-right">
- <field name="state" widget="kanban_label_selection" options="{'classes': {'draft': 'default', 'posted': 'success'}}"/>
+ <field name="state" widget="label_selection" options="{'classes': {'draft': 'default', 'posted': 'success'}}"/>
</span>
</div>
</div>
@@ -549,4 +549,4 @@
sequence="1"/>
</data>
-</odoo> \ No newline at end of file
+</odoo>
diff --git a/addons/account/views/account_payment_view.xml b/addons/account/views/account_payment_view.xml
index 135c735eecc..7dbbd1aa023 100644
--- a/addons/account/views/account_payment_view.xml
+++ b/addons/account/views/account_payment_view.xml
@@ -71,7 +71,7 @@
</div>
<div class="col-6">
<span class="float-right badge badge-secondary">
- <field name="state" widget="kanban_label_selection" options="{'classes': {'draft': 'default', 'sent': 'success'}}"/>
+ <field name="state" widget="label_selection" options="{'classes': {'draft': 'default', 'sent': 'success'}}"/>
</span>
</div>
</div>
diff --git a/addons/account/views/report_invoice.xml b/addons/account/views/report_invoice.xml
index 75140895390..bd55957c7c1 100644
--- a/addons/account/views/report_invoice.xml
+++ b/addons/account/views/report_invoice.xml
@@ -84,7 +84,7 @@
<span t-field="line.uom_id" groups="uom.group_uom"/>
</td>
<td t-attf-class="text-right {{ 'd-none d-md-table-cell' if report_type == 'html' else '' }}">
- <span t-field="line.price_unit" t-options='{"widget": "monetary", "display_currency": o.currency_id}'/>
+ <span t-field="line.price_unit"/>
</td>
<td t-if="display_discount" t-attf-class="text-right {{ 'd-none d-md-table-cell' if report_type == 'html' else '' }}">
<span t-field="line.discount"/>
diff --git a/addons/analytic/models/analytic_account.py b/addons/analytic/models/analytic_account.py
index 187d53d1ad7..91815380304 100644
--- a/addons/analytic/models/analytic_account.py
+++ b/addons/analytic/models/analytic_account.py
@@ -160,7 +160,10 @@ class AccountAnalyticAccount(models.Model):
if operator == 'ilike' and not (name or '').strip():
domain = []
else:
- domain = ['|', '|', ('code', operator, name), ('name', operator, name), ('partner_id.name', operator, name)]
+ # `partner_id` is in auto_join and the searches using ORs with auto_join fields doesn't work
+ # we have to cut the search in two searches ... https://github.com/odoo/odoo/issues/25175
+ partner_ids = self.env['res.partner']._search([('name', operator, name)], limit=limit, access_rights_uid=name_get_uid)
+ domain = ['|', '|', ('code', operator, name), ('name', operator, name), ('partner_id', 'in', partner_ids)]
analytic_account_ids = self._search(expression.AND([domain, args]), limit=limit, access_rights_uid=name_get_uid)
return self.browse(analytic_account_ids).name_get()
diff --git a/addons/auth_signup/models/res_partner.py b/addons/auth_signup/models/res_partner.py
index 85d6d732055..db66467d7dd 100644
--- a/addons/auth_signup/models/res_partner.py
+++ b/addons/auth_signup/models/res_partner.py
@@ -27,16 +27,16 @@ class ResPartner(models.Model):
signup_token = fields.Char(copy=False, groups="base.group_erp_manager")
signup_type = fields.Char(string='Signup Token Type', copy=False, groups="base.group_erp_manager")
signup_expiration = fields.Datetime(copy=False, groups="base.group_erp_manager")
- signup_valid = fields.Boolean(compute='_compute_signup_valid', compute_sudo=True, string='Signup Token is Valid')
+ signup_valid = fields.Boolean(compute='_compute_signup_valid', string='Signup Token is Valid')
signup_url = fields.Char(compute='_compute_signup_url', string='Signup URL')
@api.multi
@api.depends('signup_token', 'signup_expiration')
def _compute_signup_valid(self):
dt = now()
- for partner in self:
- partner.signup_valid = bool(partner.signup_token) and \
- (not partner.signup_expiration or dt <= partner.signup_expiration)
+ for partner, partner_sudo in zip(self, self.sudo()):
+ partner.signup_valid = bool(partner_sudo.signup_token) and \
+ (not partner_sudo.signup_expiration or dt <= partner_sudo.signup_expiration)
@api.multi
def _compute_signup_url(self):
diff --git a/addons/base_automation/data/base_automation_demo.xml b/addons/base_automation/data/base_automation_demo.xml
index 3e864e34565..7ddc3a74581 100644
--- a/addons/base_automation/data/base_automation_demo.xml
+++ b/addons/base_automation/data/base_automation_demo.xml
@@ -84,4 +84,19 @@ record['name'] = record.name + 'X'
</field>
</record>
+ <record id="test_mail_template_automation" model="mail.template">
+ <field name="name">Template Automation</field>
+ <field name="model_id" ref="base_automation.model_base_automation_lead_test"/>
+ <field name="body_html">&lt;div&gt;Email automation&lt;/div&gt;</field>
+ </record>
+ <record id="test_rule_on_write_recompute_send_email" model="base.automation">
+ <field name="name">Base Automation: test send an email</field>
+ <field name="model_id" ref="base_automation.model_base_automation_lead_test"/>
+ <field name="template_id" ref="base_automation.test_mail_template_automation"/>
+ <field name="state">email</field>
+ <field name="trigger">on_write</field>
+ <field name="active" eval="True"/>
+ <field name="filter_domain">[('deadline', '!=', False)]</field>
+ <field name="filter_pre_domain">[('deadline', '=', False)]</field>
+ </record>
</odoo>
diff --git a/addons/base_automation/models/base_automation.py b/addons/base_automation/models/base_automation.py
index ab460f1e875..e2620d202e7 100644
--- a/addons/base_automation/models/base_automation.py
+++ b/addons/base_automation/models/base_automation.py
@@ -164,14 +164,17 @@ class BaseAutomation(models.Model):
return records
def _filter_post(self, records):
+ return self._filter_post_export_domain(records)[0]
+
+ def _filter_post_export_domain(self, records):
""" Filter the records that satisfy the postcondition of action ``self``. """
if self.filter_domain and records:
domain = [('id', 'in', records.ids)] + safe_eval(self.filter_domain, self._get_eval_context())
- return records.search(domain)
+ return records.search(domain), domain
else:
- return records
+ return records, None
- def _process(self, records):
+ def _process(self, records, domain_post=None):
""" Process action ``self`` on the ``records`` that have not been done yet. """
# filter out the records on which self has already been done
action_done = self._context['__action_done']
@@ -198,7 +201,12 @@ class BaseAutomation(models.Model):
for record in records:
# we process the action if any watched field has been modified
if self._check_trigger_fields(record):
- ctx = {'active_model': record._name, 'active_ids': record.ids, 'active_id': record.id}
+ ctx = {
+ 'active_model': record._name,
+ 'active_ids': record.ids,
+ 'active_id': record.id,
+ 'domain_post': domain_post,
+ }
self.action_server_id.with_context(**ctx).run()
def _check_trigger_fields(self, record):
@@ -274,7 +282,8 @@ class BaseAutomation(models.Model):
_write.origin(records, vals, **kw)
# check postconditions, and execute actions on the records that satisfy them
for action in actions.with_context(old_values=old_values):
- action._process(action._filter_post(pre[action]))
+ records, domain_post = action._filter_post_export_domain(pre[action])
+ action._process(records, domain_post=domain_post)
return True
return _write
diff --git a/addons/base_automation/tests/test_base_automation.py b/addons/base_automation/tests/test_base_automation.py
index 6b34c65c6a7..97fcb2d1827 100644
--- a/addons/base_automation/tests/test_base_automation.py
+++ b/addons/base_automation/tests/test_base_automation.py
@@ -1,5 +1,6 @@
# # -*- coding: utf-8 -*-
# # Part of Odoo. See LICENSE file for full copyright and licensing details.
+from unittest.mock import patch
from odoo.tests import common
@@ -9,13 +10,14 @@ class base_automation_test(common.TransactionCase):
def setUp(self):
super(base_automation_test, self).setUp()
- self.user_admin = self.env.ref('base.user_root')
+ self.user_root = self.env.ref('base.user_root')
+ self.user_admin = self.env.ref('base.user_admin')
self.user_demo = self.env.ref('base.user_demo')
def create_lead(self, **kwargs):
vals = {
'name': "Lead Test",
- 'user_id': self.user_admin.id,
+ 'user_id': self.user_root.id,
}
vals.update(kwargs)
return self.env['base.automation.lead.test'].create(vals)
@@ -27,7 +29,7 @@ class base_automation_test(common.TransactionCase):
"""
lead = self.create_lead(state='open')
self.assertEqual(lead.state, 'open')
- self.assertEqual(lead.user_id, self.user_admin, "Responsible should not change on creation of Lead with state 'open'.")
+ self.assertEqual(lead.user_id, self.user_root, "Responsible should not change on creation of Lead with state 'open'.")
def test_01_check_to_state_draft_post(self):
"""
@@ -49,15 +51,15 @@ class base_automation_test(common.TransactionCase):
"""
lead = self.create_lead(state='open')
self.assertEqual(lead.state, 'open', "Lead state should be 'open'")
- self.assertEqual(lead.user_id, self.user_admin, "Responsible should not change on creation of Lead with state 'open'.")
+ self.assertEqual(lead.user_id, self.user_root, "Responsible should not change on creation of Lead with state 'open'.")
# change state to pending and check that responsible has not changed
lead.write({'state': 'pending'})
self.assertEqual(lead.state, 'pending', "Lead state should be 'pending'")
- self.assertEqual(lead.user_id, self.user_admin, "Responsible should not change on creation of Lead with state from 'draft' to 'open'.")
+ self.assertEqual(lead.user_id, self.user_root, "Responsible should not change on creation of Lead with state from 'draft' to 'open'.")
# change state to done and check that responsible has not changed
lead.write({'state': 'done'})
self.assertEqual(lead.state, 'done', "Lead state should be 'done'")
- self.assertEqual(lead.user_id, self.user_admin, "Responsible should not chang on creation of Lead with state from 'pending' to 'done'.")
+ self.assertEqual(lead.user_id, self.user_root, "Responsible should not chang on creation of Lead with state from 'pending' to 'done'.")
def test_03_check_from_draft_to_done_without_steps(self):
"""
@@ -70,7 +72,7 @@ class base_automation_test(common.TransactionCase):
"""
lead = self.create_lead(state='open')
self.assertEqual(lead.state, 'open', "Lead state should be 'open'")
- self.assertEqual(lead.user_id, self.user_admin, "Responsible should not change on creation of Lead with state 'open'.")
+ self.assertEqual(lead.user_id, self.user_root, "Responsible should not change on creation of Lead with state 'open'.")
# change state to done and check that responsible has changed
lead.write({'state': 'done'})
self.assertEqual(lead.state, 'done', "Lead state should be 'done'")
@@ -85,7 +87,7 @@ class base_automation_test(common.TransactionCase):
partner.write({'customer': False})
lead = self.create_lead(state='open', partner_id=partner.id)
self.assertFalse(lead.customer, "Customer field should updated to False")
- self.assertEqual(lead.user_id, self.user_admin, "Responsible should not change on creation of Lead with state from 'draft' to 'open'.")
+ self.assertEqual(lead.user_id, self.user_root, "Responsible should not change on creation of Lead with state from 'draft' to 'open'.")
# change partner, recompute on lead should trigger the rule
partner.write({'customer': True})
self.assertTrue(lead.customer, "Customer field should updated to True")
@@ -101,15 +103,52 @@ class base_automation_test(common.TransactionCase):
self.assertFalse(lead.deadline, 'There should not be a deadline defined')
# change priority and user; this triggers deadline recomputation, and
# the server action should set the boolean field to True
- lead.write({'priority': True, 'user_id': self.user_admin.id})
+ lead.write({'priority': True, 'user_id': self.user_root.id})
self.assertTrue(lead.deadline, 'Deadline should be defined')
self.assertTrue(lead.is_assigned_to_admin, 'Lead should be assigned to admin')
+ def test_11b_recomputed_field(self):
+ mail_automation = self.env.ref('base_automation.test_rule_on_write_recompute_send_email')
+ send_mail_count = 0
+
+ def _patched_get_actions(*args, **kwargs):
+ obj = args[0]
+ if '__action_done' not in obj._context:
+ obj = obj.with_context(__action_done={})
+ return mail_automation.with_env(obj.env)
+
+ def _patched_send_mail(*args, **kwargs):
+ nonlocal send_mail_count
+ send_mail_count += 1
+
+ patchers = [
+ patch('odoo.addons.base_automation.models.base_automation.BaseAutomation._get_actions', _patched_get_actions),
+ patch('odoo.addons.mail.models.mail_template.MailTemplate.send_mail', _patched_send_mail),
+ ]
+
+ patchers[0].start()
+
+ lead = self.create_lead()
+ self.assertFalse(lead.priority)
+ self.assertFalse(lead.deadline)
+
+ patchers[1].start()
+
+ lead.write({'priority': True})
+
+ self.assertTrue(lead.priority)
+ self.assertTrue(lead.deadline)
+
+ for patcher in patchers:
+ patcher.stop()
+
+ self.assertEqual(send_mail_count, 1)
+
def test_12_recursive(self):
""" Check that a rule is executed recursively by a secondary change. """
lead = self.create_lead(state='open')
self.assertEqual(lead.state, 'open')
- self.assertEqual(lead.user_id, self.user_admin)
+ self.assertEqual(lead.user_id, self.user_root)
# change partner; this should trigger the rule that modifies the state
partner = self.env.ref('base.res_partner_1')
lead.write({'partner_id': partner.id})
diff --git a/addons/base_import/static/csv/o2m_customers_contacts.csv b/addons/base_import/static/csv/o2m_customers_contacts.csv
index bf142142d3c..d29a600f18b 100644
--- a/addons/base_import/static/csv/o2m_customers_contacts.csv
+++ b/addons/base_import/static/csv/o2m_customers_contacts.csv
@@ -1,9 +1,9 @@
Name,Is a company,Related company,Address type,Customer,Supplier,Street,ZIP,City,State,Country
-Aurora Shelves,1,,,1,0,25 Pacific Road,95101,San José,CA,United States
-Roger Martins,0,Aurora Shelves,Invoice address,1,0,27 Pacific Road,95102,San José,CA,United States
-House Sales Direct,1,,,1,0,104 Saint Mary Avenue,94059,Redwood,CA,United States
-Yvan Holiday,0,House Sales Direct,Contact,1,0,104 Saint Mary Avenue,94060,Redwood,CA,United States
-Jack Unsworth,0,House Sales Direct,Invoice address,1,0,227 Jackson Road,94061,Redwood,CA,United States
-Michael Mason,0,,,1,0,16 5th Avenue,94104,San Francisco,CA,United States
-International Wood,1,,,1,0,748 White House Boulevard,20004,Washington,DC,United States
-Sharon Pecker,0,International Wood,Invoice address,1,0,755 White House Boulevard,20005,Washington,DC,United States
+Aurora Shelves,1,,,1,0,25 Pacific Road,95101,San José,California,United States
+Roger Martins,0,Aurora Shelves,Invoice address,1,0,27 Pacific Road,95102,San José,California,United States
+House Sales Direct,1,,,1,0,104 Saint Mary Avenue,94059,Redwood,California,United States
+Yvan Holiday,0,House Sales Direct,Contact,1,0,104 Saint Mary Avenue,94060,Redwood,California,United States
+Jack Unsworth,0,House Sales Direct,Invoice address,1,0,227 Jackson Road,94061,Redwood,California,United States
+Michael Mason,0,,,1,0,16 5th Avenue,94104,San Francisco,California,United States
+International Wood,1,,,1,0,748 White House Boulevard,20004,Washington,D.C.,United States
+Sharon Pecker,0,International Wood,Invoice address,1,0,755 White House Boulevard,20005,Washington,D.C.,United States
diff --git a/addons/crm/models/crm_team.py b/addons/crm/models/crm_team.py
index 30d7cc945ea..a101fb26848 100644
--- a/addons/crm/models/crm_team.py
+++ b/addons/crm/models/crm_team.py
@@ -31,7 +31,10 @@ class Team(models.Model):
compute='_compute_overdue_opportunities',
string='Overdue Opportunities Revenues')
- alias_user_id = fields.Many2one(domain=lambda self: [
+ # Since we are in a _inherits case, this is not an override
+ # but a plain definition of a field
+ # So we need to reset the property related of that field
+ alias_user_id = fields.Many2one('res.users', related='alias_id.alias_user_id', inherited=True, domain=lambda self: [
('groups_id', 'in', self.env.ref('sales_team.group_sale_salesman_all_leads').id)])
def _compute_unassigned_leads_count(self):
diff --git a/addons/event_sale/models/sale_order.py b/addons/event_sale/models/sale_order.py
index 1b15093de03..fac1730bead 100644
--- a/addons/event_sale/models/sale_order.py
+++ b/addons/event_sale/models/sale_order.py
@@ -73,6 +73,11 @@ class SaleOrderLine(models.Model):
if self.event_ticket_id and (not self.event_id or self.event_id != self.event_ticket_id.event_id):
self.event_ticket_id = None
+ @api.onchange('product_uom', 'product_uom_qty')
+ def product_uom_change(self):
+ if not self.event_ticket_id:
+ super(SaleOrderLine, self).product_uom_change()
+
@api.onchange('event_ticket_id')
def _onchange_event_ticket_id(self):
company = self.event_id.company_id or self.env.user.company_id
diff --git a/addons/gamification/data/gamification_karma_rank_data.xml b/addons/gamification/data/gamification_karma_rank_data.xml
index 2a5ad0231fe..3833dd24b7e 100644
--- a/addons/gamification/data/gamification_karma_rank_data.xml
+++ b/addons/gamification/data/gamification_karma_rank_data.xml
@@ -1,20 +1,75 @@
<?xml version="1.0" encoding="utf-8"?>
-<odoo>
- <data noupdate="1">
- <!--Karma-->
- <record id="base.user_root" model="res.users">
- <field name="karma">2500</field>
- </record>
- <record id="base.user_admin" model="res.users">
- <field name="karma">2500</field>
- </record>
+<odoo><data noupdate="1">
+ <!--Karma-->
+ <record id="base.user_root" model="res.users">
+ <field name="karma">2500</field>
+ </record>
+ <record id="base.user_admin" model="res.users">
+ <field name="karma">2500</field>
+ </record>
- <!--Ranks-->
- <record id="rank_newbie" model="gamification.karma.rank">
- <field name="name">Newbie</field>
- <field name="description" type="html"><p>You just began the adventure! Welcome!</p></field>
- <field name="karma_min">1</field>
- <field name="image" type="base64" file="gamification/static/img/rank_newbie_badge.svg"/>
- </record>
- </data>
-</odoo> \ No newline at end of file
+ <!--Ranks-->
+ <record id="rank_newbie" model="gamification.karma.rank">
+ <field name="name">Newbie</field>
+ <field name="description" type="html"><p>You just began the adventure! Welcome!</p></field>
+ <field name="description_motivational" type="html">
+ <div class="media align-items-center">
+ <div class="media-body">Reach the next rank to show the rest of the world you exist!</div>
+ <img class="ml-3 img img-fluid" style="max-height: 72px;" src="gamification/static/img/rank_student_badge.svg"/>
+ </div>
+ </field>
+ <field name="karma_min">1</field>
+ <field name="image" type="base64" file="gamification/static/img/rank_newbie_badge.svg"/>
+ </record>
+
+ <record id="rank_student" model="gamification.karma.rank">
+ <field name="name">Student</field>
+ <field name="description" type="html"><p>You're a young padawan now. May the force be with you!</p></field>
+ <field name="description_motivational" type="html">
+ <div class="media align-items-center">
+ <div class="media-body">Reach the next rank to improve your status.</div>
+ <img class="ml-3 img img-fluid" style="max-height: 72px;" src="gamification/static/img/rank_bachelor_badge.svg"/>
+ </div>
+ </field>
+ <field name="karma_min">100</field>
+ <field name="image" type="base64" file="gamification/static/img/rank_student_badge.svg"/>
+ </record>
+
+ <record id="rank_bachelor" model="gamification.karma.rank">
+ <field name="name">Bachelor</field>
+ <field name="description" type="html"><p>You love learning things. Curiosity is a good way to progress.</p></field>
+ <field name="description_motivational" type="html">
+ <div class="media align-items-center">
+ <div class="media-body">Reach the next rank and become a Master!</div>
+ <img class="ml-3 img img-fluid" style="max-height: 72px;" src="gamification/static/img/rank_master_badge.svg"/>
+ </div>
+ </field>
+ <field name="karma_min">500</field>
+ <field name="image" type="base64" file="gamification/static/img/rank_bachelor_badge.svg"/>
+ </record>
+
+ <record id="rank_master" model="gamification.karma.rank">
+ <field name="name">Master</field>
+ <field name="description" type="html"><p>You know what you are talking about. People learn from you.</p></field>
+ <field name="description_motivational" type="html">
+ <div class="media align-items-center">
+ <div class="media-body">Reach the next rank and become a powerful user!</div>
+ <img class="ml-3 img img-fluid" style="max-height: 72px;" src="gamification/static/img/rank_doctor_badge.svg"/>
+ </div>
+ </field>
+ <field name="karma_min">2000</field>
+ <field name="image" type="base64" file="gamification/static/img/rank_master_badge.svg"/>
+ </record>
+
+ <record id="rank_doctor" model="gamification.karma.rank">
+ <field name="name">Doctor</field>
+ <field name="description" type="html"><p>You have reached the last rank. Congratulations!</p></field>
+ <field name="description_motivational" type="html">
+ <div class="media align-items-center">
+ <div class="media-body">You have reached the last rank. Congratulations!</div>
+ </div>
+ </field>
+ <field name="karma_min">10000</field>
+ <field name="image" type="base64" file="gamification/static/img/rank_doctor_badge.svg"/>
+ </record>
+</data></odoo>
diff --git a/addons/gamification/data/gamification_karma_rank_demo.xml b/addons/gamification/data/gamification_karma_rank_demo.xml
index 26c639a8cc9..c2d81ebbcc7 100644
--- a/addons/gamification/data/gamification_karma_rank_demo.xml
+++ b/addons/gamification/data/gamification_karma_rank_demo.xml
@@ -10,51 +10,39 @@
<!--Ranks-->
<record id="rank_student" model="gamification.karma.rank">
- <field name="name">Student</field>
- <field name="description" type="html"><p>You begin to understand things ! Keep going!</p></field>
<field name="description_motivational" type="html">
-<div class="media align-items-center">
- <div class="media-body">Reach the next rank and gain a very nice mug !</div>
- <img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_misc_mug.png"/>
-</div></field>
- <field name="karma_min">100</field>
- <field name="image" type="base64" file="gamification/static/img/rank_student_badge.svg"/>
+ <div class="media align-items-center">
+ <div class="media-body">Reach the next rank and gain a very nice mug !</div>
+ <img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_misc_mug.png"/>
+ </div>
+ </field>
</record>
<record id="rank_bachelor" model="gamification.karma.rank">
- <field name="name">Bachelor</field>
- <field name="description" type="html"><p>Hey ! You're not so dumb after all !</p></field>
<field name="description_motivational" type="html">
-<div class="media align-items-center">
- <div class="media-body">Reach the next rank and gain a very magic wand !</div>
- <img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_misc_wand.png"/>
-</div></field>
- <field name="karma_min">500</field>
- <field name="image" type="base64" file="gamification/static/img/rank_bachelor_badge.svg"/>
+ <div class="media align-items-center">
+ <div class="media-body">Reach the next rank and gain a very magic wand !</div>
+ <img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_misc_wand.png"/>
+ </div>
+ </field>
</record>
<record id="rank_master" model="gamification.karma.rank">
- <field name="name">Master</field>
- <field name="description" type="html"><p>You're good ! Really Good !</p></field>
<field name="description_motivational" type="html">
-<div class="media align-items-center">
- <div class="media-body">Reach the next rank and gain a very nice hat !</div>
- <img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_misc_hat.png"/>
-</div></field>
- <field name="karma_min">2000</field>
- <field name="image" type="base64" file="gamification/static/img/rank_master_badge.svg"/>
+ <div class="media align-items-center">
+ <div class="media-body">Reach the next rank and gain a very nice hat !</div>
+ <img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_misc_hat.png"/>
+ </div>
+ </field>
</record>
<record id="rank_doctor" model="gamification.karma.rank">
- <field name="name">Doctor</field>
- <field name="description" type="html"><p>Ok, RESPECT !</p></field>
<field name="description_motivational" type="html">
-<div class="media align-items-center">
- <div class="media-body">Reach the next rank and gain a week-end with a unicorn !</div>
- <img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_misc_unicorn.png"/>
-</div></field>
- <field name="karma_min">10000</field>
- <field name="image" type="base64" file="gamification/static/img/rank_doctor_badge.svg"/>
+ <div class="media align-items-center">
+ <div class="media-body">Reach the next rank and gain a week-end with a unicorn !</div>
+ <img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_misc_unicorn.png"/>
+ </div>
+ </field>
</record>
</data></odoo>
diff --git a/addons/gamification/models/badge.py b/addons/gamification/models/badge.py
index 849f1744f46..f092a6dd522 100644
--- a/addons/gamification/models/badge.py
+++ b/addons/gamification/models/badge.py
@@ -259,7 +259,7 @@ class GamificationBadge(models.Model):
elif self.rule_auth == 'users' and self.env.user not in self.rule_auth_user_ids:
return self.USER_NOT_VIP
elif self.rule_auth == 'having':
- all_user_badges = self.env['gamification.badge.user'].search([('user_id', '=', self.env.uid)])
+ all_user_badges = self.env['gamification.badge.user'].search([('user_id', '=', self.env.uid)]).mapped('badge_id')
if self.rule_auth_badge_ids - all_user_badges:
return self.BADGE_REQUIRED
diff --git a/addons/gamification/models/res_users.py b/addons/gamification/models/res_users.py
index 8f59f6c7591..f556dcc514a 100644
--- a/addons/gamification/models/res_users.py
+++ b/addons/gamification/models/res_users.py
@@ -47,9 +47,10 @@ class Users(models.Model):
@api.multi
def write(self, vals):
+ result = super(Users, self).write(vals)
if 'karma' in vals:
self._recompute_rank()
- return super(Users, self).write(vals)
+ return result
@api.multi
def add_karma(self, karma):
@@ -72,13 +73,16 @@ class Users(models.Model):
self.env['gamification.karma.rank'].search([], order="karma_min DESC")]
for user in self:
old_rank = user.rank_id
- for i in range(0, len(ranks)):
- if user.karma >= ranks[i]['karma_min']:
- user.write({
- 'rank_id': ranks[i]['rank'].id,
- 'next_rank_id': ranks[i - 1]['rank'].id if i > 0 else False
- })
- break
+ if user.karma == 0 and ranks:
+ user.write({'next_rank_id': ranks[-1]['rank'].id})
+ else:
+ for i in range(0, len(ranks)):
+ if user.karma >= ranks[i]['karma_min']:
+ user.write({
+ 'rank_id': ranks[i]['rank'].id,
+ 'next_rank_id': ranks[i - 1]['rank'].id if 0 < i else False
+ })
+ break
if old_rank != user.rank_id:
user._rank_changed()
diff --git a/addons/gamification/static/img/rank_misc_world.png b/addons/gamification/static/img/rank_misc_world.png
new file mode 100644
index 00000000000..087b7e7bd1d
--- /dev/null
+++ b/addons/gamification/static/img/rank_misc_world.png
Binary files differ
diff --git a/addons/hr/models/hr.py b/addons/hr/models/hr.py
index 209235ff42b..7ce2db69071 100644
--- a/addons/hr/models/hr.py
+++ b/addons/hr/models/hr.py
@@ -360,13 +360,13 @@ class Employee(models.Model):
self = self.sudo(real_user)
return self
- def _message_log(self, body='', subject=False, message_type='notification', **kwargs):
- return super(Employee, self._post_author()).message_post(body=body, subject=subject, message_type=message_type, **kwargs)
+ def _message_log(self, *args, **kwargs):
+ return super(Employee, self._post_author())._message_log(*args, **kwargs)
@api.multi
@api.returns('mail.message', lambda value: value.id)
- def message_post(self, **kwargs):
- return super(Employee, self._post_author()).message_post(**kwargs)
+ def message_post(self, *args, **kwargs):
+ return super(Employee, self._post_author()).message_post(*args, **kwargs)
class Department(models.Model):
diff --git a/addons/hr_fleet/controllers/i18n/az.po b/addons/hr_fleet/controllers/i18n/az.po
deleted file mode 100644
index 23fa2458e4d..00000000000
--- a/addons/hr_fleet/controllers/i18n/az.po
+++ /dev/null
@@ -1,115 +0,0 @@
-# Translation of Odoo Server.
-# This file contains the translation of the following modules:
-# * crm_project
-#
-msgid ""
-msgstr ""
-"Project-Id-Version: Odoo Server saas~11.5\n"
-"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2018-09-21 13:17+0000\n"
-"PO-Revision-Date: 2018-08-24 09:17+0000\n"
-"Language-Team: Azerbaijani (https://www.transifex.com/odoo/teams/41243/az/)\n"
-"MIME-Version: 1.0\n"
-"Content-Type: text/plain; charset=UTF-8\n"
-"Content-Transfer-Encoding: \n"
-"Language: az\n"
-"Plural-Forms: nplurals=2; plural=(n != 1);\n"
-
-#. module: crm_project
-#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_view_form
-msgid ""
-"<span class=\"o_stat_text\">Convert To</span>\n"
-" <span class=\"o_stat_text\">Task</span>"
-msgstr ""
-
-#. module: crm_project
-#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form
-msgid "Cancel"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.actions.act_window,name:crm_project.crm_lead_convert2task_action
-#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form
-#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_view_form
-msgid "Convert to Task"
-msgstr ""
-
-#. module: crm_project
-#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form
-msgid "Create Task"
-msgstr ""
-
-#. module: crm_project
-#: selection:crm.lead.convert2task,action:0
-msgid "Create a new customer"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__create_uid
-msgid "Created by"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__create_date
-msgid "Created on"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__partner_id
-msgid "Customer"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__display_name
-msgid "Display Name"
-msgstr ""
-
-#. module: crm_project
-#: selection:crm.lead.convert2task,action:0
-msgid "Do not link to a customer"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__id
-msgid "ID"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task____last_update
-msgid "Last Modified on"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__write_uid
-msgid "Last Updated by"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__write_date
-msgid "Last Updated on"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__lead_id
-msgid "Lead"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model,name:crm_project.model_crm_lead_convert2task
-msgid "Lead convert to Task"
-msgstr ""
-
-#. module: crm_project
-#: selection:crm.lead.convert2task,action:0
-msgid "Link to an existing customer"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__project_id
-msgid "Project"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__action
-msgid "Related Customer"
-msgstr ""
diff --git a/addons/hr_fleet/controllers/i18n/fil.po b/addons/hr_fleet/controllers/i18n/fil.po
deleted file mode 100644
index f3c56bf488c..00000000000
--- a/addons/hr_fleet/controllers/i18n/fil.po
+++ /dev/null
@@ -1,115 +0,0 @@
-# Translation of Odoo Server.
-# This file contains the translation of the following modules:
-# * crm_project
-#
-msgid ""
-msgstr ""
-"Project-Id-Version: Odoo Server saas~11.5\n"
-"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2018-09-21 13:17+0000\n"
-"PO-Revision-Date: 2018-08-24 09:17+0000\n"
-"Language-Team: Filipino (https://www.transifex.com/odoo/teams/41243/fil/)\n"
-"MIME-Version: 1.0\n"
-"Content-Type: text/plain; charset=UTF-8\n"
-"Content-Transfer-Encoding: \n"
-"Language: fil\n"
-"Plural-Forms: nplurals=2; plural=(n == 1 || n==2 || n==3) || (n % 10 != 4 || n % 10 != 6 || n % 10 != 9);\n"
-
-#. module: crm_project
-#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_view_form
-msgid ""
-"<span class=\"o_stat_text\">Convert To</span>\n"
-" <span class=\"o_stat_text\">Task</span>"
-msgstr ""
-
-#. module: crm_project
-#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form
-msgid "Cancel"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.actions.act_window,name:crm_project.crm_lead_convert2task_action
-#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form
-#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_view_form
-msgid "Convert to Task"
-msgstr ""
-
-#. module: crm_project
-#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form
-msgid "Create Task"
-msgstr ""
-
-#. module: crm_project
-#: selection:crm.lead.convert2task,action:0
-msgid "Create a new customer"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__create_uid
-msgid "Created by"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__create_date
-msgid "Created on"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__partner_id
-msgid "Customer"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__display_name
-msgid "Display Name"
-msgstr ""
-
-#. module: crm_project
-#: selection:crm.lead.convert2task,action:0
-msgid "Do not link to a customer"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__id
-msgid "ID"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task____last_update
-msgid "Last Modified on"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__write_uid
-msgid "Last Updated by"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__write_date
-msgid "Last Updated on"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__lead_id
-msgid "Lead"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model,name:crm_project.model_crm_lead_convert2task
-msgid "Lead convert to Task"
-msgstr ""
-
-#. module: crm_project
-#: selection:crm.lead.convert2task,action:0
-msgid "Link to an existing customer"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__project_id
-msgid "Project"
-msgstr ""
-
-#. module: crm_project
-#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__action
-msgid "Related Customer"
-msgstr ""
diff --git a/addons/hr_gamification/wizard/gamification_badge_user_wizard.py b/addons/hr_gamification/wizard/gamification_badge_user_wizard.py
index 4e486a0e048..7c4c174129f 100644
--- a/addons/hr_gamification/wizard/gamification_badge_user_wizard.py
+++ b/addons/hr_gamification/wizard/gamification_badge_user_wizard.py
@@ -10,7 +10,7 @@ class GamificationBadgeUserWizard(models.TransientModel):
employee_id = fields.Many2one('hr.employee', string='Employee', required=True)
user_id = fields.Many2one('res.users', string='User', related='employee_id.user_id',
- store=False, readonly=True)
+ store=False, readonly=True, compute_sudo=True)
@api.multi
def action_grant_badge(self):
diff --git a/addons/hr_holidays/models/hr_leave.py b/addons/hr_holidays/models/hr_leave.py
index 459bd63b3df..200d7937d1c 100644
--- a/addons/hr_holidays/models/hr_leave.py
+++ b/addons/hr_holidays/models/hr_leave.py
@@ -111,13 +111,12 @@ class HolidaysRequest(models.Model):
"\nThe status is 'Approved', when time off request is approved by manager.")
payslip_status = fields.Boolean('Reported in last payslips', help='Green this button when the time off has been taken into account in the payslip.')
report_note = fields.Text('HR Comments')
- user_id = fields.Many2one('res.users', string='User', related='employee_id.user_id', related_sudo=True, store=True, default=lambda self: self.env.uid, readonly=True)
+ user_id = fields.Many2one('res.users', string='User', related='employee_id.user_id', related_sudo=True, compute_sudo=True, store=True, default=lambda self: self.env.uid, readonly=True)
# leave type configuration
holiday_status_id = fields.Many2one(
"hr.leave.type", string="Time Off Type", required=True, readonly=True,
states={'draft': [('readonly', False)], 'confirm': [('readonly', False)]},
domain=[('valid', '=', True)])
- leave_type_request_unit = fields.Selection(related='holiday_status_id.request_unit', readonly=True)
validation_type = fields.Selection('Validation Type', related='holiday_status_id.validation_type', readonly=False)
# HR data
employee_id = fields.Many2one(
diff --git a/addons/hr_holidays/models/hr_leave_allocation.py b/addons/hr_holidays/models/hr_leave_allocation.py
index 8ac1d7d10bd..da978c4ae60 100644
--- a/addons/hr_holidays/models/hr_leave_allocation.py
+++ b/addons/hr_holidays/models/hr_leave_allocation.py
@@ -252,7 +252,7 @@ class HolidaysAllocation(models.Model):
if self.env.user.employee_ids:
self.department_id = self.department_id or self.env.user.employee_ids[0].department_id
self.employee_id = None
- elif self.holiday_type == 'category':
+ else:
self.employee_id = None
self.department_id = None
diff --git a/addons/hr_holidays/report/hr_leave_reports.xml b/addons/hr_holidays/report/hr_leave_reports.xml
index 90e1affc20c..f7da796a104 100644
--- a/addons/hr_holidays/report/hr_leave_reports.xml
+++ b/addons/hr_holidays/report/hr_leave_reports.xml
@@ -77,7 +77,7 @@
<div>
<strong class="o_kanban_record_title"><t t-esc="record.employee_id.value"/></strong>
<span class="float-right">
- <field name="state" widget="kanban_label_selection" options="{'classes': {'draft': 'default', 'validate': 'success','confirm': 'default', 'cancel': 'danger'}}"/>
+ <field name="state" widget="label_selection" options="{'classes': {'draft': 'default', 'validate': 'success','confirm': 'default', 'cancel': 'danger'}}"/>
</span>
</div>
<div class="text-muted o_kanban_record_subtitle">
diff --git a/addons/hr_holidays/security/hr_holidays_security.xml b/addons/hr_holidays/security/hr_holidays_security.xml
index 1d534a94648..8bc6852d3fe 100644
--- a/addons/hr_holidays/security/hr_holidays_security.xml
+++ b/addons/hr_holidays/security/hr_holidays_security.xml
@@ -8,6 +8,7 @@
<record id="group_hr_holidays_team_leader" model="res.groups">
<field name="name">Team Approver</field>
<field name="category_id" ref="base.module_category_human_resources_time_off"/>
+ <field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
</record>
<record id="group_hr_holidays_user" model="res.groups">
@@ -48,7 +49,7 @@
<record id="hr_leave_rule_team_leader_update" model="ir.rule">
<field name="name">Time Off: approver: write on employee's time off in his team</field>
<field name="model_id" ref="model_hr_leave"/>
- <field name="domain_force">[('employee_id.leave_manager_id', '=' , user.id), ('holiday_type', '=', 'employee')]</field>
+ <field name="domain_force">['&amp;', ('holiday_type', '=', 'employee'), '|', ('employee_id.leave_manager_id', '=' , user.id), ('employee_id.parent_id.user_id', '=' , user.id)]</field>
<field name="perm_read" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
@@ -123,6 +124,16 @@
<field name="groups" eval="[(4, ref('group_hr_holidays_manager'))]"/>
</record>
+ <record id="hr_leave_allocation_rule_team_leader" model="ir.rule">
+ <field name="name">Allocations: Team Leader: read employee's allocations in his team</field>
+ <field name="model_id" ref="model_hr_leave_allocation"/>
+ <field name="domain_force">['|', ('employee_id.leave_manager_id', '=' , user.id), ('employee_id.parent_id.user_id', '=' , user.id)]</field>
+ <field name="perm_write" 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="resource_leaves_team_leader" model="ir.rule">
<field name="name">Time Off Resources: Approver</field>
<field name="model_id" ref="model_resource_calendar_leaves"/>
diff --git a/addons/hr_holidays/tests/common.py b/addons/hr_holidays/tests/common.py
index 0f9c6f1cc89..510c6883b41 100644
--- a/addons/hr_holidays/tests/common.py
+++ b/addons/hr_holidays/tests/common.py
@@ -23,6 +23,9 @@ class TestHrHolidaysBase(common.TransactionCase):
self.user_hrmanager_2 = mail_new_test_user(self.env, login='florence', groups='base.group_user,hr_holidays.group_hr_holidays_manager')
self.user_hrmanager_2_id = self.user_hrmanager_2.id
+ self.user_team_leader = mail_new_test_user(self.env, login='arthur', groups='base.group_user,hr_holidays.group_hr_holidays_team_leader')
+ self.user_team_leader_id = self.user_team_leader.id
+
# Hr Data
Department = self.env['hr.department'].with_context(tracking_disable=True)
diff --git a/addons/hr_holidays/tests/test_access_rights.py b/addons/hr_holidays/tests/test_access_rights.py
index ca651a69010..00ac2856d20 100644
--- a/addons/hr_holidays/tests/test_access_rights.py
+++ b/addons/hr_holidays/tests/test_access_rights.py
@@ -362,6 +362,37 @@ class TestLeavesRights(TestHrHolidaysBase):
self.employee_leave.sudo(self.user_hruser).action_approve()
self.employee_leave.sudo(self.user_hrmanager_id).action_validate()
+ # ----------------------------------------
+ # Validation: team leader
+ # ----------------------------------------
+
+ @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail')
+ def test_leave_validation_team_leader_other(self):
+ """ Team Leader validates its employee's leave only"""
+ self.leave_type.write({'allocation_type': 'fixed'})
+ self.env['hr.leave.allocation'].create({
+ 'holiday_status_id': self.leave_type.id,
+ 'employee_id': self.employee_emp.id,
+ 'number_of_days': 10,
+ }).action_approve()
+ with self.assertRaises(AccessError):
+ self.employee_leave.sudo(self.user_team_leader).action_approve()
+
+ @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail')
+ def test_leave_validation_team_leader(self):
+ """ Team Leader validates its employee's leave"""
+ self.leave_type.write({'allocation_type': 'fixed'})
+
+ # set the team leader
+ self.employee_emp.leave_manager_id = self.user_team_leader
+
+ self.env['hr.leave.allocation'].create({
+ 'holiday_status_id': self.leave_type.id,
+ 'employee_id': self.employee_emp.id,
+ 'number_of_days': 10,
+ }).action_approve()
+ self.employee_leave.sudo(self.user_team_leader).action_approve()
+
class TestMultiCompany(TestHrHolidaysBase):
diff --git a/addons/hr_holidays/tests/test_leave_requests.py b/addons/hr_holidays/tests/test_leave_requests.py
index 67d468b0a44..ea025d571cd 100644
--- a/addons/hr_holidays/tests/test_leave_requests.py
+++ b/addons/hr_holidays/tests/test_leave_requests.py
@@ -138,7 +138,7 @@ class TestLeaveRequests(TestHrHolidaysBase):
'number_of_days': 1,
})
- @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail')
+ @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail', 'odoo.models')
def test_accrual_validity_time_not_valid(self):
""" Employee ask leav during a not valid validity time """
with self.assertRaises(ValidationError):
diff --git a/addons/hr_payroll/models/hr_benefit.py b/addons/hr_payroll/models/hr_benefit.py
index 6c3367fd3aa..f75e115108d 100644
--- a/addons/hr_payroll/models/hr_benefit.py
+++ b/addons/hr_payroll/models/hr_benefit.py
@@ -275,9 +275,9 @@ class HrBenefit(models.Model):
}]
self.env['resource.calendar.attendance'].create(vals_list)
- @api.model
- def action_validate(self, ids):
- benefits = self.env['hr.benefit'].search([('id', 'in', ids), ('state', '!=', 'validated')])
+ @api.multi
+ def action_validate(self):
+ benefits = self.filtered(lambda benefit: benefit.state != 'validated')
benefits.write({'display_warning': False})
if not benefits._check_if_error():
benefits.write({'state': 'validated'})
@@ -311,4 +311,4 @@ class Contacts(models.Model):
_sql_constraints = [
('user_id_employee_id_unique', 'UNIQUE(user_id,employee_id)', 'You cannot have twice the same employee.')
- ] \ No newline at end of file
+ ]
diff --git a/addons/hr_payroll/tests/test_benefit.py b/addons/hr_payroll/tests/test_benefit.py
index 083a9c287ac..3100ff6db43 100644
--- a/addons/hr_payroll/tests/test_benefit.py
+++ b/addons/hr_payroll/tests/test_benefit.py
@@ -1,10 +1,8 @@
# # -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from datetime import datetime
import pytz
from dateutil.relativedelta import relativedelta
-from odoo import exceptions
from odoo.fields import Datetime, Date
from odoo.tests.common import tagged
from odoo.addons.hr_payroll.tests.common import TestPayslipBase
@@ -130,7 +128,7 @@ class TestBenefit(TestPayslipBase):
'date_stop': end,
'benefit_type_id': self.benefit_type.id,
})
- benefit.action_validate(benefit.ids)
+ benefit.action_validate()
benefits = self.env['hr.benefit'].search([('employee_id', '=', self.richard_emp.id)])
self.assertTrue(all((b.state == 'validated' for b in benefits)), "Benefits should be approved")
self.assertEqual(len(benefits), 3, "Benefit should be split in three")
@@ -204,7 +202,7 @@ class TestBenefit(TestPayslipBase):
'date_start': start,
'date_stop': end,
})
- benef.action_validate(benef.ids)
+ benef.action_validate()
calendar_leave = self.env['resource.calendar.leaves'].search([('name', '=', 'Richard leave from benef')])
self.assertTrue(calendar_leave, "It should have created a leave in the calendar")
self.assertEqual(calendar_leave.benefit_type_id, benef.benefit_type_id, "It should have the same benefit type")
@@ -220,7 +218,7 @@ class TestBenefit(TestPayslipBase):
'date_start': start,
'date_stop': end + relativedelta(hours=5),
})
- benef2 = self.env['hr.benefit'].create({
+ self.env['hr.benefit'].create({
'name': '2',
'employee_id': self.richard_emp.id,
'benefit_type_id': self.env.ref('hr_payroll.benefit_type_attendance').id,
@@ -228,7 +226,7 @@ class TestBenefit(TestPayslipBase):
'date_start': start + relativedelta(hours=3),
'date_stop': end,
})
- self.assertFalse(benef1.action_validate(benef1.ids), "It should not validate benefits conflicting with others")
+ self.assertFalse(benef1.action_validate(), "It should not validate benefits conflicting with others")
self.assertTrue(benef1.display_warning)
self.assertNotEqual(benef1.state, 'validated')
@@ -241,7 +239,7 @@ class TestBenefit(TestPayslipBase):
'date_start': self.start,
'date_stop': self.end,
})
- leave_1 = self.env['hr.leave'].create({
+ self.env['hr.leave'].create({
'name': 'Doctor Appointment',
'employee_id': self.richard_emp.id,
'holiday_status_id': self.leave_type.id,
@@ -249,7 +247,7 @@ class TestBenefit(TestPayslipBase):
'date_to': self.start + relativedelta(days=1),
'number_of_days': 2,
})
- self.assertFalse(benef1.action_validate(benef1.ids),"It should not validate benefits conflicting with non approved leaves")
+ self.assertFalse(benef1.action_validate(), "It should not validate benefits conflicting with non approved leaves")
self.assertTrue(benef1.display_warning)
def test_validate_undefined_benefit(self):
@@ -260,7 +258,7 @@ class TestBenefit(TestPayslipBase):
'date_start': self.start,
'date_stop': self.end,
})
- self.assertFalse(benef1.action_validate(benef1.ids),"It should not validate benefits without a type")
+ self.assertFalse(benef1.action_validate(), "It should not validate benefits without a type")
def test_approve_leave_benefit(self):
start = self.to_datetime_tz('2015-11-01 09:00:00')
@@ -273,7 +271,7 @@ class TestBenefit(TestPayslipBase):
'date_to': start + relativedelta(days=1),
'number_of_days': 2,
})
- benef = self.env['hr.benefit'].create({
+ self.env['hr.benefit'].create({
'name': '1',
'employee_id': self.richard_emp.id,
'contract_id': self.richard_emp.contract_id.id,
@@ -299,7 +297,7 @@ class TestBenefit(TestPayslipBase):
self.assertTrue(new_benef, "It should have created a benefit for the last two days")
self.assertTrue(new_leave_benef, "It should have created a leave benefit for the first day")
- self.assertTrue(benef.action_validate((new_benef | new_leave_benef).ids), "It should be able to validate the benefits")
+ self.assertTrue((new_benef | new_leave_benef).action_validate(), "It should be able to validate the benefits")
def test_refuse_leave_benefit(self):
start = self.to_datetime_tz('2015-11-01 09:00:00')
@@ -321,7 +319,7 @@ class TestBenefit(TestPayslipBase):
'date_stop': end,
'leave_id': leave.id
})
- benef.action_validate(benef.ids)
+ benef.action_validate()
self.assertTrue(benef.display_warning, "It should have an error (conflicting leave to approve")
leave.action_refuse()
self.assertFalse(benef.display_warning, "It should not have an error")
@@ -342,7 +340,7 @@ class TestBenefit(TestPayslipBase):
'date_start': start,
'date_stop': end,
})
- benef.action_validate(benef.ids)
+ benef.action_validate()
data = self.richard_emp._get_benefit_days_data(self.benefit_type, self.start, self.end)
self.assertEqual(data['hours'], 7.0)
@@ -358,7 +356,7 @@ class TestBenefit(TestPayslipBase):
'date_start': start,
'date_stop': end,
})
- leave_benef.action_validate(leave_benef.ids)
+ leave_benef.action_validate()
data = self.richard_emp._get_benefit_days_data(self.benefit_type_leave, self.start, self.end)
self.assertEqual(data['hours'], 5.0, "It should equal the number of hours richard should have worked")
@@ -374,7 +372,7 @@ class TestBenefit(TestPayslipBase):
'date_start': start,
'date_stop': end,
})
- leave_benef.action_validate(leave_benef.ids)
+ leave_benef.action_validate()
data = self.richard_emp._get_benefit_days_data(self.benefit_type_leave, self.start, self.end)
self.assertEqual(data['hours'], 0.0, "It should equal the number of hours richard should have worked")
@@ -390,7 +388,7 @@ class TestBenefit(TestPayslipBase):
'date_start': start,
'date_stop': end,
})
- leave_benef.action_validate(leave_benef.ids)
+ leave_benef.action_validate()
payslip_wizard = self.env['hr.payslip.employees'].create({'employee_ids': [(4, self.richard_emp.id)]})
payslip_wizard.with_context({'default_date_start': Date.to_string(start), 'default_date_end': Date.to_string(end)}).compute_sheet()
payslip = self.env['hr.payslip'].search([('employee_id', '=', self.richard_emp.id)])
@@ -414,7 +412,7 @@ class TestBenefit(TestPayslipBase):
'date_start': start,
'date_stop': end,
})
- benef.action_validate(benef.ids)
+ benef.action_validate()
payslip_wizard = self.env['hr.payslip.employees'].create({'employee_ids': [(4, self.richard_emp.id)]})
payslip_wizard.with_context({
'default_date_start': Date.to_string(start),
diff --git a/addons/hr_skills/static/src/js/resume_widget.js b/addons/hr_skills/static/src/js/resume_widget.js
index 4249fca884b..7bb2023b76f 100644
--- a/addons/hr_skills/static/src/js/resume_widget.js
+++ b/addons/hr_skills/static/src/js/resume_widget.js
@@ -90,7 +90,7 @@ var AbstractGroupedOne2ManyRenderer = ListRenderer.extend({
return qweb.render('hr_trash_button');
},
- _renderAddItemButton(group) {
+ _renderAddItemButton: function (group) {
return qweb.render(this.addLineButtonTemplate, {
context: JSON.stringify(this._getCreateLineContext(group)),
});
@@ -141,8 +141,8 @@ var ResumeLineRenderer = AbstractGroupedOne2ManyRenderer.extend({
var date_start = data.date_start && data.date_start.format(dateFormat) || "";
var date_end = data.date_end && data.date_end.format(dateFormat) || _t("Current");
return _.extend(data, {
- date_start,
- date_end,
+ date_start: date_start,
+ date_end: date_end,
});
},
diff --git a/addons/l10n_be_hr_payroll/views/res_config_settings_views.xml b/addons/l10n_be_hr_payroll/views/res_config_settings_views.xml
index f65c72e610e..947cfbb98f7 100644
--- a/addons/l10n_be_hr_payroll/views/res_config_settings_views.xml
+++ b/addons/l10n_be_hr_payroll/views/res_config_settings_views.xml
@@ -18,52 +18,52 @@
</div>
<group class="mt16">
<label for="default_holidays"/>
- <div class="o_row">
+ <div>
<field name="default_holidays" nolabel="1"/>
<span>days / year</span>
</div>
<label for="default_commission_on_target"/>
- <div class="o_row">
+ <div>
<field name="default_commission_on_target" nolabel="1"/>
<span>€ / month</span>
</div>
<label for="default_fuel_card"/>
- <div class="o_row">
+ <div>
<field name="default_fuel_card" nolabel="1"/>
<span>€ / month</span>
</div>
<label for="default_representation_fees"/>
- <div class="o_row">
+ <div>
<field name="default_representation_fees" nolabel="1"/>
<span>€ / month</span>
</div>
<label for="default_internet"/>
- <div class="o_row">
+ <div>
<field name="default_internet" nolabel="1"/>
<span>€ / month</span>
</div>
<label for="default_mobile"/>
- <div class="o_row">
+ <div>
<field name="default_mobile" nolabel="1"/>
<span>€ / month</span>
</div>
<label for="default_internet"/>
- <div class="o_row">
+ <div>
<field name="default_internet" nolabel="1"/>
<span>€ / month</span>
</div>
<label for="default_mobile_plus"/>
- <div class="o_row">
+ <div>
<field name="default_mobile_plus" nolabel="1"/>
<span>€ / month</span>
</div>
<label for="default_meal_voucher_amount"/>
- <div class="o_row">
+ <div>
<field name="default_meal_voucher_amount" nolabel="1"/>
<span>€ / month</span>
</div>
<label for="default_eco_checks"/>
- <div class="o_row">
+ <div>
<field name="default_eco_checks" nolabel="1"/>
<span>€ / year</span>
</div>
diff --git a/addons/l10n_fr_fec/wizard/account_fr_fec.py b/addons/l10n_fr_fec/wizard/account_fr_fec.py
index 25dcb719b5c..d91f4858c88 100644
--- a/addons/l10n_fr_fec/wizard/account_fr_fec.py
+++ b/addons/l10n_fr_fec/wizard/account_fr_fec.py
@@ -76,6 +76,27 @@ class AccountFrFec(models.TransientModel):
listrow = list(row)
return listrow
+ def _get_company_legal_data(self, company):
+ """
+ Dom-Tom are excluded from the EU's fiscal territory
+ Those regions do not have SIREN
+ sources:
+ https://www.service-public.fr/professionnels-entreprises/vosdroits/F23570
+ http://www.douane.gouv.fr/articles/a11024-tva-dans-les-dom
+ """
+ dom_tom_group = self.env.ref('l10n_fr.dom-tom')
+ is_dom_tom = company.country_id.code in dom_tom_group.country_ids.mapped('code')
+ if not is_dom_tom and not company.vat:
+ raise Warning(
+ _("Missing VAT number for company %s") % company.name)
+ if not is_dom_tom and company.vat[0:2] != 'FR':
+ raise Warning(
+ _("FEC is for French companies only !"))
+
+ return {
+ 'siren': company.vat[4:13] if not is_dom_tom else '',
+ }
+
@api.multi
def generate_fec(self):
self.ensure_one()
@@ -87,6 +108,9 @@ class AccountFrFec(models.TransientModel):
# 2) CSV files are easier to read/use for a regular accountant.
# So it will be easier for the accountant to check the file before
# sending it to the fiscal administration
+ company = self.env.user.company_id
+ company_legal_data = self._get_company_legal_data(company)
+
header = [
u'JournalCode', # 0
u'JournalLib', # 1
@@ -108,18 +132,7 @@ class AccountFrFec(models.TransientModel):
u'Idevise', # 17
]
- company = self.env.user.company_id
- if not company.vat:
- raise Warning(
- _("Missing VAT number for company %s") % company.name)
- if company.vat[0:2] != 'FR':
- raise Warning(
- _("FEC is for French companies only !"))
-
- fecfile = io.BytesIO()
- w = pycompat.csv_writer(fecfile, delimiter='|')
- w.writerow(header)
-
+ rows_to_write = [header]
# INITIAL BALANCE
unaffected_earnings_xml_ref = self.env.ref('account.data_unaffected_earnings')
unaffected_earnings_line = True # used to make sure that we add the unaffected earning initial balance only once
@@ -195,7 +208,8 @@ class AccountFrFec(models.TransientModel):
else:
listrow[11] = '0,00'
listrow[12] = str(-listrow_amount).replace('.', ',')
- w.writerow(listrow)
+ rows_to_write.append(listrow)
+
#if the unaffected earnings account wasn't in the selection yet: add it manually
if (not unaffected_earnings_line
and unaffected_earnings_results
@@ -206,7 +220,7 @@ class AccountFrFec(models.TransientModel):
if unaffected_earnings_account:
unaffected_earnings_results[4] = unaffected_earnings_account.code
unaffected_earnings_results[5] = unaffected_earnings_account.name
- w.writerow(unaffected_earnings_results)
+ rows_to_write.append(unaffected_earnings_results)
# INITIAL BALANCE - receivable/payable
sql_query = '''
@@ -264,7 +278,7 @@ class AccountFrFec(models.TransientModel):
for row in self._cr.fetchall():
listrow = list(row)
account_id = listrow.pop()
- w.writerow(listrow)
+ rows_to_write.append(listrow)
# LINES
sql_query = '''
@@ -329,20 +343,19 @@ class AccountFrFec(models.TransientModel):
sql_query, (self.date_from, self.date_to, company.id))
for row in self._cr.fetchall():
- w.writerow(list(row))
+ rows_to_write.append(list(row))
- siren = company.vat[4:13]
+ fecvalue = self._csv_write_rows(rows_to_write)
end_date = fields.Date.to_string(self.date_to).replace('-', '')
suffix = ''
if self.export_type == "nonofficial":
suffix = '-NONOFFICIAL'
- fecvalue = fecfile.getvalue()
+
self.write({
'fec_data': base64.encodestring(fecvalue),
# Filename = <siren>FECYYYYMMDD where YYYMMDD is the closing date
- 'filename': '%sFEC%s%s.csv' % (siren, end_date, suffix),
+ 'filename': '%sFEC%s%s.csv' % (company_legal_data['siren'], end_date, suffix),
})
- fecfile.close()
action = {
'name': 'FEC',
@@ -351,3 +364,29 @@ class AccountFrFec(models.TransientModel):
'target': 'self',
}
return action
+
+ def _csv_write_rows(self, rows, lineterminator=u'\r\n'):
+ """
+ Write FEC rows into a file
+ It seems that Bercy's bureaucracy is not too happy about the
+ empty new line at the End Of File.
+
+ @param {list(list)} rows: the list of rows. Each row is a list of strings
+ @param {unicode string} [optional] lineterminator: effective line terminator
+ Has nothing to do with the csv writer parameter
+ The last line written won't be terminated with it
+
+ @return the value of the file
+ """
+ fecfile = io.BytesIO()
+ writer = pycompat.csv_writer(fecfile, delimiter='|', lineterminator='')
+
+ rows_length = len(rows)
+ for i, row in enumerate(rows):
+ if not i == rows_length - 1:
+ row.append(lineterminator)
+ writer.writerow(row)
+
+ fecvalue = fecfile.getvalue()
+ fecfile.close()
+ return fecvalue
diff --git a/addons/l10n_multilang/models/l10n_multilang.py b/addons/l10n_multilang/models/l10n_multilang.py
index 4bde8505d5c..08ecb01a665 100644
--- a/addons/l10n_multilang/models/l10n_multilang.py
+++ b/addons/l10n_multilang/models/l10n_multilang.py
@@ -87,11 +87,20 @@ class AccountChartTemplate(models.Model):
return self.process_translations(langs, field, in_ids, out_ids)
def _get_template_from_model(self, company_id, model):
+ """ Find the records and their matching template """
+ # genereated records have an external id with the format <company id>_<template id>
out_data = self.env['ir.model.data'].search([('model', '=', model), ('name', '=like', str(company_id)+'\_%')])
- out_ids = self.env[model].search([('id', 'in', out_data.mapped('res_id'))], order='id')
+ out_ids = self.env[model].browse(out_data.mapped('res_id'))
+
+ # templates and records may have been created in a different order
+ # reorder them based on external id names
in_xml_id_names = [xml_id.partition(str(company_id) + '_')[-1] for xml_id in out_data.mapped('name')]
- in_xml_ids = self.env['ir.model.data'].search([('model', '=', model+'.template'), ('name', 'in', in_xml_id_names)])
- in_ids = self.env[model+'.template'].search([('id', 'in', in_xml_ids.mapped('res_id'))], order='id')
+ in_xml_id_names = {name: index for index, name in enumerate(in_xml_id_names)}
+
+ in_xml_ids = self.env['ir.model.data'].search([('model', '=', model+'.template'), ('name', 'in', list(in_xml_id_names))])
+ in_xml_ids = in_xml_ids.sorted(key=lambda x: in_xml_id_names[x.name])
+
+ in_ids = self.env[model+'.template'].browse(in_xml_ids.mapped('res_id'))
return (in_ids, out_ids)
class BaseLanguageInstall(models.TransientModel):
diff --git a/addons/lunch/static/src/scss/lunch_kanban.scss b/addons/lunch/static/src/scss/lunch_kanban.scss
index 2e589ed912f..7c50ae36358 100644
--- a/addons/lunch/static/src/scss/lunch_kanban.scss
+++ b/addons/lunch/static/src/scss/lunch_kanban.scss
@@ -1,11 +1,3 @@
-.o_list_view {
- .o_data_row {
- .o_text_overflow {
- @include o-text-overflow(table-cell);
- }
- }
-}
-
.o_lunch_kanban {
display: flex;
flex-direction: column; // display lunch widget above kanban renderer
diff --git a/addons/lunch/views/lunch_orders_views.xml b/addons/lunch/views/lunch_orders_views.xml
index db39dabbc29..0a637869776 100644
--- a/addons/lunch/views/lunch_orders_views.xml
+++ b/addons/lunch/views/lunch_orders_views.xml
@@ -61,7 +61,7 @@
<div class="o_kanban_record_headings">
<strong class="o_kanban_record_title"><field name="product_id"/></strong>
</div>
- <field name="state" widget="kanban_label_selection" options="{'classes': {'new': 'default', 'confirmed': 'success', 'cancelled':'danger'}}"/>
+ <field name="state" widget="label_selection" options="{'classes': {'new': 'default', 'confirmed': 'success', 'cancelled':'danger'}}"/>
</div>
<div>
<field name="note"/>
diff --git a/addons/mail/models/ir_actions.py b/addons/mail/models/ir_actions.py
index bfecd028152..40582b1d803 100644
--- a/addons/mail/models/ir_actions.py
+++ b/addons/mail/models/ir_actions.py
@@ -80,18 +80,27 @@ class ServerActions(models.Model):
def _is_recompute(self, action):
"""When an activity is set on update of a record,
update might be triggered many times by recomputes.
- When need to know it to skip these steps."""
+ When need to know it to skip these steps.
+ Except if the computed field is supposed to trigger the action
+ """
records = self.env[action.model_id.model].browse(
self._context.get('active_ids', self._context.get('active_id')))
old_values = action._context.get('old_values')
if old_values:
- field_names = list(old_values[records[0].id])
- field = records._fields[field_names[0]]
- # Pick an arbitrary field; if it is marked to be recomputed,
- # it means we are in an extraneous write triggered by the recompute.
- # In this case, we should not create a new activity.
- if records._recompute_check(field):
- return True
+ domain_post = action._context.get('domain_post')
+ tracked_fields = []
+ if domain_post:
+ for leaf in domain_post:
+ if isinstance(leaf, (tuple, list)):
+ tracked_fields.append(leaf[0])
+ fields_to_check = [field for record, field_names in old_values.items() for field in field_names if field not in tracked_fields]
+ if fields_to_check:
+ field = records._fields[fields_to_check[0]]
+ # Pick an arbitrary field; if it is marked to be recomputed,
+ # it means we are in an extraneous write triggered by the recompute.
+ # In this case, we should not create a new activity.
+ if records._recompute_check(field):
+ return True
return False
@api.model
diff --git a/addons/mail/models/mail_activity.py b/addons/mail/models/mail_activity.py
index d8ace68fd62..70072a16785 100644
--- a/addons/mail/models/mail_activity.py
+++ b/addons/mail/models/mail_activity.py
@@ -130,9 +130,9 @@ class MailActivity(models.Model):
activity_type_id = fields.Many2one(
'mail.activity.type', string='Activity Type',
domain="['|', ('res_model_id', '=', False), ('res_model_id', '=', res_model_id)]", ondelete='restrict')
- activity_category = fields.Selection(related='activity_type_id.category', readonly=False)
- activity_decoration = fields.Selection(related='activity_type_id.decoration_type', readonly=False)
- icon = fields.Char('Icon', related='activity_type_id.icon', readonly=False)
+ activity_category = fields.Selection(related='activity_type_id.category', readonly=True)
+ activity_decoration = fields.Selection(related='activity_type_id.decoration_type', readonly=True)
+ icon = fields.Char('Icon', related='activity_type_id.icon', readonly=True)
summary = fields.Char('Summary')
note = fields.Html('Note')
date_deadline = fields.Date('Due Date', index=True, required=True, default=fields.Date.context_today)
@@ -149,14 +149,14 @@ class MailActivity(models.Model):
('today', 'Today'),
('planned', 'Planned')], 'State',
compute='_compute_state')
- recommended_activity_type_id = fields.Many2one('mail.activity.type', string="Recommended Activity Type")
- previous_activity_type_id = fields.Many2one('mail.activity.type', string='Previous Activity Type')
+ recommended_activity_type_id = fields.Many2one('mail.activity.type', string="Recommended Activity Type", readonly=True)
+ previous_activity_type_id = fields.Many2one('mail.activity.type', string='Previous Activity Type', readonly=True)
has_recommended_activities = fields.Boolean(
'Next activities available',
compute='_compute_has_recommended_activities',
help='Technical field for UX purpose')
- mail_template_ids = fields.Many2many(related='activity_type_id.mail_template_ids', readonly=False)
- force_next = fields.Boolean(related='activity_type_id.force_next', readonly=False)
+ mail_template_ids = fields.Many2many(related='activity_type_id.mail_template_ids', readonly=True)
+ force_next = fields.Boolean(related='activity_type_id.force_next', readonly=True)
# access
can_write = fields.Boolean(compute='_compute_can_write', help='Technical field to hide buttons if the current user has no access.')
@@ -296,6 +296,12 @@ class MailActivity(models.Model):
activity.user_id.display_name)
else:
try:
+ target_user = activity.user_id
+ target_record = self.env[activity.res_model].browse(activity.res_id)
+ if hasattr(target_record, 'company_id') and (
+ target_record.company_id != target_user.company_id and (
+ len(target_user.company_ids) > 1)):
+ return # in that case we skip the check, assuming it would fail because of the company
model.browse(activity.res_id).check_access_rule('read')
except exceptions.AccessError:
raise exceptions.UserError(
diff --git a/addons/mail/models/mail_tracking_value.py b/addons/mail/models/mail_tracking_value.py
index 707f3f6bd75..570757a6534 100644
--- a/addons/mail/models/mail_tracking_value.py
+++ b/addons/mail/models/mail_tracking_value.py
@@ -40,7 +40,9 @@ class MailTracking(models.Model):
def _compute_groups(self):
for tracking in self:
model = self.env[tracking.mail_message_id.model]
- tracking.groups = model._fields[tracking.field].groups
+ field = model._fields.get(tracking.field)
+ # field may not exist anymore if module has been uninstalled
+ tracking.groups = field.groups if field else 'base.group_system'
@api.model
def create_tracking_values(self, initial_value, new_value, col_name, col_info, tracking_sequence):
diff --git a/addons/mail/static/src/js/composers/mention_manager.js b/addons/mail/static/src/js/composers/mention_manager.js
index 7ad8a36b39a..8aa8b251343 100644
--- a/addons/mail/static/src/js/composers/mention_manager.js
+++ b/addons/mail/static/src/js/composers/mention_manager.js
@@ -53,7 +53,7 @@ var MentionManager = Widget.extend({
var listener = _.findWhere(this._listeners, { delimiter: delimiter });
if (listener) {
var escapedVal = _.escape(this._composer.$input.val());
- var inputMentions = escapedVal.match(new RegExp(delimiter+'[^ ]+(?= |&nbsp;)', 'g'));
+ var inputMentions = escapedVal.match(new RegExp(delimiter+'[^ ]+(?= |&nbsp;|$)', 'g'));
return this._validateSelection(listener.selection, inputMentions);
}
return [];
@@ -166,9 +166,8 @@ var MentionManager = Widget.extend({
listener.model,
listener.delimiter,
matchName);
- var subtext = s.substring(startIndex, endIndex)
- .replace(match[0], processedText);
- substrings.push(subtext);
+ substrings.push(s.substring(startIndex, match.index));
+ substrings.push(processedText);
startIndex = endIndex;
}
substrings.push(s.substring(startIndex, s.length));
@@ -258,7 +257,7 @@ var MentionManager = Widget.extend({
// create the regex of all mention's names
var names = _.pluck(listener.selection, 'name');
var escapedNames = _.map(names, function (str) {
- return "("+_.str.escapeRegExp(listener.delimiter+str)+")";
+ return "("+_.str.escapeRegExp(listener.delimiter+str)+")(?= |&nbsp;|$)";
});
var regexStr = escapedNames.join('|');
// extract matches
diff --git a/addons/mail/static/src/js/thread_widget.js b/addons/mail/static/src/js/thread_widget.js
index 56945533946..ee4e543dbe9 100644
--- a/addons/mail/static/src/js/thread_widget.js
+++ b/addons/mail/static/src/js/thread_widget.js
@@ -466,7 +466,7 @@ var ThreadWidget = Widget.extend({
return message.getID() === messageID;
});
return QWeb.render('mail.widget.Thread.Message.MailTooltip', {
- data: message.getCustomerEmailData()
+ data: message.hasCustomerEmailData() ? message.getCustomerEmailData() : [],
});
},
});
diff --git a/addons/mail/static/tests/discuss_tests.js b/addons/mail/static/tests/discuss_tests.js
index 7dc8be4320c..f25c7082648 100644
--- a/addons/mail/static/tests/discuss_tests.js
+++ b/addons/mail/static/tests/discuss_tests.js
@@ -507,6 +507,77 @@ QUnit.test('@ mention with special chars', async function (assert) {
discuss.destroy();
});
+QUnit.test('@ mention with removed space', async function (assert) {
+ assert.expect(4);
+ var fetchListenersDef = testUtils.makeTestPromise();
+ var receiveMessageDef = testUtils.makeTestPromise();
+ this.data.initMessaging = {
+ channel_slots: {
+ channel_channel: [{
+ id: 1,
+ channel_type: "channel",
+ name: "general",
+ }],
+ },
+ };
+ var objectDiscuss;
+ var discuss = await createDiscuss({
+ id: 1,
+ context: {},
+ params: {},
+ data: this.data,
+ services: this.services,
+ mockRPC: function (route, args) {
+ if (args.method === 'channel_fetch_listeners') {
+ fetchListenersDef.resolve();
+ return Promise.resolve([
+ {id: 1, name: 'Admin'},
+ ]);
+ }
+ if (args.method === 'message_post') {
+ assert.deepEqual(args.kwargs.partner_ids, [],
+ "mentioned partners are sent to server"
+ );
+ var data = {
+ author_id: ["42", "Me"],
+ body: args.kwargs.body,
+ channel_ids: [1],
+ };
+ var notification = [[false, 'mail.channel', 1], data];
+ objectDiscuss.call('bus_service', 'trigger', 'notification', [notification]);
+ receiveMessageDef.resolve();
+ return Promise.resolve(42);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ objectDiscuss = discuss;
+ var $general = discuss.$('.o_mail_discuss_sidebar')
+ .find('.o_mail_discuss_item[data-thread-id=1]');
+ // click on general
+ $general.click();
+ var $input = discuss.$('textarea.o_composer_text_field').first();
+ assert.ok($input.length, "should display a composer input");
+ // Note: focus is needed in order to trigger rpc 'channel_fetch_listeners'
+ $input.focus();
+ $input.val("@");
+ $input.trigger('keyup');
+ await fetchListenersDef;
+ await testUtils.nextTick();
+ // equivalent to $mentionPropositions.find('active').click();
+ $input.trigger($.Event('keyup', {which: $.ui.keyCode.ENTER}));
+ $input.val('@Admin: hi');
+ // send message
+ await testUtils.fields.triggerKeydown($input, 'enter');
+ await receiveMessageDef;
+ await testUtils.nextTick();
+ assert.containsNone(discuss, '.o_thread_message_content a',
+ "should not contain a link in the message content");
+ assert.strictEqual(discuss.$('.o_thread_message_content').html().trim(),
+ "@Admin: hi", "should not have linked mention without space");
+ discuss.destroy();
+});
+
QUnit.test('@ mention in mailing channel', async function (assert) {
assert.expect(8);
diff --git a/addons/mass_mailing/models/mass_mailing.py b/addons/mass_mailing/models/mass_mailing.py
index c83f52f68c6..2d569425119 100644
--- a/addons/mass_mailing/models/mass_mailing.py
+++ b/addons/mass_mailing/models/mass_mailing.py
@@ -4,10 +4,12 @@
import hashlib
import hmac
import logging
+import lxml
import random
import re
import threading
from ast import literal_eval
+from base64 import b64encode
from odoo import api, fields, models, tools, _, SUPERUSER_ID
from odoo.exceptions import UserError
@@ -26,6 +28,10 @@ MASS_MAILING_BUSINESS_MODELS = [
'mail.mass_mailing.contact'
]
+# Syntax of the data URL Scheme: https://tools.ietf.org/html/rfc2397#section-3
+# Used to find inline images
+image_re = re.compile(r"data:(image/[A-Za-z]+);base64,(.*)")
+
class MassMailingTag(models.Model):
"""Model of categories of mass mailing, i.e. marketing, newsletter, ... """
@@ -675,6 +681,53 @@ class MassMailing(models.Model):
if len(record_lists) > 0:
record.sudo().message_post(body=_(message % ', '.join(str(list.name) for list in record_lists.mapped('list_id'))))
+ @api.model
+ def create(self, values):
+ if values.get('body_html'):
+ values['body_html'] = self._convert_inline_images_to_urls(values['body_html'])
+ return super(MassMailing, self).create(values)
+
+ def write(self, values):
+ if values.get('body_html'):
+ values['body_html'] = self._convert_inline_images_to_urls(values['body_html'])
+ return super(MassMailing, self).write(values)
+
+ def _convert_inline_images_to_urls(self, body_html):
+ """
+ Find inline base64 encoded images, make an attachement out of
+ them and replace the inline image with an url to the attachement.
+ """
+
+ def _image_to_url(b64image: bytes):
+ """Store an image in an attachement and returns an url"""
+ attachment = self.env['ir.attachment'].create({
+ 'name': "cropped_image",
+ 'datas': b64image,
+ 'datas_fname': "cropped_image_mailing_{}".format(self.id),
+ 'type': 'binary',})
+
+ attachment.generate_access_token()
+
+ return '/web/image/%s?access_token=%s' % (
+ attachment.id, attachment.access_token)
+
+
+ modified = False
+ root = lxml.html.fromstring(body_html)
+ for node in root.iter('img'):
+ match = image_re.match(node.attrib.get('src', ''))
+ if match:
+ mime = match.group(1) # unsed
+ image = match.group(2).encode() # base64 image as bytes
+
+ node.attrib['src'] = _image_to_url(image)
+ modified = True
+
+ if modified:
+ return lxml.html.tostring(root)
+
+ return body_html
+
#------------------------------------------------------
# Views & Actions
#------------------------------------------------------
diff --git a/addons/mass_mailing/static/src/js/mass_mailing_widget.js b/addons/mass_mailing/static/src/js/mass_mailing_widget.js
index 866213b9092..ad3e047ec87 100644
--- a/addons/mass_mailing/static/src/js/mass_mailing_widget.js
+++ b/addons/mass_mailing/static/src/js/mass_mailing_widget.js
@@ -43,6 +43,9 @@ var MassMailingFieldHtml = FieldHtml.extend({
*/
commitChanges: function () {
var self = this;
+ if (!this.wysiwyg || !this.isRendered) {
+ return this._super();
+ }
var fieldName = this.nodeOptions['inline-field'];
if (this.mode == "readonly") {
diff --git a/addons/mass_mailing/tests/test_mass_mailing_shortener.py b/addons/mass_mailing/tests/test_mass_mailing_shortener.py
index 456498f3c74..d279208264d 100644
--- a/addons/mass_mailing/tests/test_mass_mailing_shortener.py
+++ b/addons/mass_mailing/tests/test_mass_mailing_shortener.py
@@ -56,6 +56,7 @@ class TestMassMailingShortener(common.TransactionCase):
"contact_list_ids": [[6, False, [mailing_list_A.id]]],
"mass_mailing_campaign_id": False,
"name": "sdf",
+ "subject": "test_00_test_mass_mailing_shortener",
"body_html": """
Hi,
% set url = "www.odoo.com"
diff --git a/addons/mass_mailing/views/unsubscribe_templates.xml b/addons/mass_mailing/views/unsubscribe_templates.xml
index 3e89131f7e5..bf7b4b54e84 100644
--- a/addons/mass_mailing/views/unsubscribe_templates.xml
+++ b/addons/mass_mailing/views/unsubscribe_templates.xml
@@ -46,7 +46,7 @@
<div class="btn btn-secondary pull-right" id="button_add_blacklist" style="display:none">Blacklist Me</div>
</div>
<div class="btn btn-secondary pull-right" id="button_remove_blacklist" style="display:none">Come Back</div>
- <input type="submit" id="send_form" class="btn btn-primary" value="Update my subscriptions"/>
+ <button type="submit" id="send_form" class="btn btn-primary">Update my subscriptions</button>
</div>
</div>
diff --git a/addons/mrp/models/mrp_abstract_workorder.py b/addons/mrp/models/mrp_abstract_workorder.py
index 4a6e65648e4..0cf35f4c7e8 100644
--- a/addons/mrp/models/mrp_abstract_workorder.py
+++ b/addons/mrp/models/mrp_abstract_workorder.py
@@ -84,10 +84,9 @@ class MrpAbstractWorkorder(models.AbstractModel):
for move_line in move_raw.move_line_ids:
# Get workorder lines that match reservation.
candidates = move_workorder_lines._find_candidate(move_line)
- while candidates:
+ for candidate in candidates:
if float_compare(qty_todo, 0, precision_rounding=rounding) <= 0:
break
- candidate = candidates.pop()
qty_to_add = move_line.product_uom_qty - candidate.qty_done
line_values['to_update'][candidate] = {
'qty_done': candidate.qty_done + qty_to_add,
@@ -136,7 +135,7 @@ class MrpAbstractWorkorder(models.AbstractModel):
'qty_to_consume': to_consume_in_line,
'qty_reserved': move_line.product_uom_qty,
'lot_id': move_line.lot_id.id,
- 'qty_done': is_tracked and 0 or to_consume_in_line
+ 'qty_done': to_consume_in_line
}
lines.append(line)
qty_to_consume -= to_consume_in_line
@@ -159,7 +158,7 @@ class MrpAbstractWorkorder(models.AbstractModel):
'product_id': move.product_id.id,
'product_uom_id': move.product_uom.id,
'qty_to_consume': qty_to_consume,
- 'qty_done': is_tracked and 0 or qty_to_consume
+ 'qty_done': qty_to_consume
}
lines.append(line)
return lines
diff --git a/addons/mrp/models/mrp_bom.py b/addons/mrp/models/mrp_bom.py
index 311b116c586..02418f2c588 100644
--- a/addons/mrp/models/mrp_bom.py
+++ b/addons/mrp/models/mrp_bom.py
@@ -94,6 +94,8 @@ class MrpBom(models.Model):
self.product_uom_id = self.product_tmpl_id.uom_id.id
if self.product_id.product_tmpl_id != self.product_tmpl_id:
self.product_id = False
+ for line in self.bom_line_ids:
+ line.attribute_value_ids = False
@api.onchange('routing_id')
def onchange_routing_id(self):
@@ -292,6 +294,8 @@ class MrpBomLine(models.Model):
@api.onchange('parent_product_tmpl_id')
def onchange_parent_product(self):
+ if not self.parent_product_tmpl_id:
+ return {}
return {'domain': {'attribute_value_ids': [
('id', 'in', self.parent_product_tmpl_id._get_valid_product_attribute_values().ids),
('attribute_id.create_variant', '!=', 'no_variant')
diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py
index ba2afbc20dc..11c96450c2b 100644
--- a/addons/mrp/models/mrp_production.py
+++ b/addons/mrp/models/mrp_production.py
@@ -605,7 +605,8 @@ class MrpProduction(models.Model):
for move_raw in production.move_raw_ids:
move_raw.write({
'group_id': production.procurement_group_id.id,
- 'unit_factor': move_raw.product_uom_qty / production.product_qty
+ 'unit_factor': move_raw.product_uom_qty / production.product_qty,
+ 'reference': self.name, # set reference when MO name is different than 'New'
})
production._generate_finished_moves()
production.move_raw_ids._adjust_procure_method()
@@ -825,8 +826,8 @@ class MrpProduction(models.Model):
moves_to_finish = order.move_finished_ids.filtered(lambda x: x.state not in ('done','cancel'))
moves_to_finish._action_done()
order.action_assign()
- consume_move_lines = moves_to_do.mapped('active_move_line_ids')
- for moveline in moves_to_finish.mapped('active_move_line_ids'):
+ consume_move_lines = moves_to_do.mapped('move_line_ids')
+ for moveline in moves_to_finish.mapped('move_line_ids'):
if moveline.product_id == order.product_id and moveline.move_id.has_tracking != 'none':
if any([not ml.lot_produced_id for ml in consume_move_lines]):
raise UserError(_('You can not consume without telling for which lot you consumed it'))
diff --git a/addons/mrp/models/mrp_workorder.py b/addons/mrp/models/mrp_workorder.py
index 9991be74969..e96527e1554 100644
--- a/addons/mrp/models/mrp_workorder.py
+++ b/addons/mrp/models/mrp_workorder.py
@@ -209,9 +209,6 @@ class MrpWorkorder(models.Model):
'location_dest_id': by_product_move.location_dest_id.id,
}
- def _link_to_quality_check(self, old_move_line, new_move_line):
- return True
-
@api.multi
def record_production(self):
if not self:
@@ -229,10 +226,6 @@ class MrpWorkorder(models.Model):
# TODO: should be same as checking if for every workorder something has been done?
if not self.next_work_order_id:
self._update_finished_move()
- self.production_id.move_finished_ids.filtered(
- lambda move: move.product_id == self.product_id and
- move.state not in ('done', 'cancel')
- ).workorder_id = self.id
# Transfer quantities from temporary to final move line or make them final
self._update_raw_moves()
diff --git a/addons/mrp/models/stock_move.py b/addons/mrp/models/stock_move.py
index 85822eaa519..c04cdb2a063 100644
--- a/addons/mrp/models/stock_move.py
+++ b/addons/mrp/models/stock_move.py
@@ -16,7 +16,6 @@ class StockMoveLine(models.Model):
lot_produced_qty = fields.Float(
'Quantity Finished Product', digits=dp.get_precision('Product Unit of Measure'),
help="Informative, not used in matching")
- done_wo = fields.Boolean('Done for Work Order', default=True, help="Technical Field which is False when temporarily filled in in work order") # TDE FIXME: naming
done_move = fields.Boolean('Move Done', related='move_id.is_done', readonly=False, store=True) # TDE FIXME: naming
def _get_similar_move_lines(self):
@@ -24,12 +23,11 @@ class StockMoveLine(models.Model):
if self.move_id.production_id:
finished_moves = self.move_id.production_id.move_finished_ids
finished_move_lines = finished_moves.mapped('move_line_ids')
- lines |= finished_move_lines.filtered(lambda ml: ml.product_id == self.product_id and (ml.lot_id or ml.lot_name) and ml.done_wo == self.done_wo)
+ lines |= finished_move_lines.filtered(lambda ml: ml.product_id == self.product_id and (ml.lot_id or ml.lot_name))
if self.move_id.raw_material_production_id:
raw_moves = self.move_id.raw_material_production_id.move_raw_ids
raw_moves_lines = raw_moves.mapped('move_line_ids')
- raw_moves_lines |= self.move_id.active_move_line_ids
- lines |= raw_moves_lines.filtered(lambda ml: ml.product_id == self.product_id and (ml.lot_id or ml.lot_name) and ml.done_wo == self.done_wo)
+ lines |= raw_moves_lines.filtered(lambda ml: ml.product_id == self.product_id and (ml.lot_id or ml.lot_name))
return lines
def _reservation_is_updatable(self, quantity, reserved_quant):
@@ -46,7 +44,7 @@ class StockMoveLine(models.Model):
for move_line in self:
if move_line.move_id.production_id and 'lot_id' in vals:
move_line.production_id.move_raw_ids.mapped('move_line_ids')\
- .filtered(lambda r: r.done_wo and not r.done_move and r.lot_produced_id == move_line.lot_id)\
+ .filtered(lambda r: not r.done_move and r.lot_produced_id == move_line.lot_id)\
.write({'lot_produced_id': vals['lot_id']})
production = move_line.move_id.production_id or move_line.move_id.raw_material_production_id
if production and move_line.state == 'done' and any(field in vals for field in ('lot_id', 'location_id', 'qty_done')):
@@ -71,7 +69,6 @@ class StockMove(models.Model):
workorder_id = fields.Many2one(
'mrp.workorder', 'Work Order To Consume')
# Quantities to process, in normalized UoMs
- active_move_line_ids = fields.One2many('stock.move.line', 'move_id', domain=[('done_wo', '=', True)], string='Lots')
bom_line_id = fields.Many2one('mrp.bom.line', 'BoM Line')
unit_factor = fields.Float('Unit Factor')
is_done = fields.Boolean(
@@ -89,10 +86,6 @@ class StockMove(models.Model):
.filtered(lambda ml: ml.qty_done == 0.0)\
.write({'move_id': new_move, 'product_uom_qty': 0})
- @api.depends('active_move_line_ids.qty_done', 'active_move_line_ids.product_uom_id')
- def _compute_done_quantity(self):
- super(StockMove, self)._compute_done_quantity()
-
@api.depends('raw_material_production_id.move_finished_ids.move_line_ids.lot_id')
def _compute_order_finished_lot_ids(self):
for move in self:
@@ -116,13 +109,6 @@ class StockMove(models.Model):
if move.raw_material_production_id:
move.is_locked = move.raw_material_production_id.is_locked
- def _get_move_lines(self):
- self.ensure_one()
- if self.raw_material_production_id:
- return self.active_move_line_ids
- else:
- return super(StockMove, self)._get_move_lines()
-
@api.depends('state')
def _compute_is_done(self):
for move in self:
diff --git a/addons/mrp/models/stock_rule.py b/addons/mrp/models/stock_rule.py
index 4efbb4464ce..8f72e133c24 100644
--- a/addons/mrp/models/stock_rule.py
+++ b/addons/mrp/models/stock_rule.py
@@ -90,6 +90,7 @@ class StockRule(models.Model):
'picking_type_id': self.picking_type_id.id or values['warehouse_id'].manu_type_id.id,
'company_id': company_id.id,
'move_dest_ids': values.get('move_dest_ids') and [(4, x.id) for x in values['move_dest_ids']] or False,
+ 'user_id': False,
}
def _get_date_planned(self, product_id, company_id, values):
diff --git a/addons/mrp/models/stock_warehouse.py b/addons/mrp/models/stock_warehouse.py
index c66d15131c6..59fa4f39e36 100644
--- a/addons/mrp/models/stock_warehouse.py
+++ b/addons/mrp/models/stock_warehouse.py
@@ -168,9 +168,21 @@ class StockWarehouse(models.Model):
values = super(StockWarehouse, self)._get_locations_values(vals)
def_values = self.default_get(['manufacture_steps'])
manufacture_steps = vals.get('manufacture_steps', def_values['manufacture_steps'])
+ code = vals.get('code') or self.code
+ code = code.replace(' ', '').upper()
values.update({
- 'pbm_loc_id': {'name': _('Pre-Production'), 'active': manufacture_steps in ('pbm', 'pbm_sam'), 'usage': 'internal'},
- 'sam_loc_id': {'name': _('Post-Production'), 'active': manufacture_steps == 'pbm_sam', 'usage': 'internal'},
+ 'pbm_loc_id': {
+ 'name': _('Pre-Production'),
+ 'active': manufacture_steps in ('pbm', 'pbm_sam'),
+ 'usage': 'internal',
+ 'barcode': code + '-PREPRODUCTION'
+ },
+ 'sam_loc_id': {
+ 'name': _('Post-Production'),
+ 'active': manufacture_steps == 'pbm_sam',
+ 'usage': 'internal',
+ 'barcode': code + '-POSTPRODUCTION'
+ },
})
return values
diff --git a/addons/mrp/tests/test_order.py b/addons/mrp/tests/test_order.py
index 25e693f3d0f..f970e5055e4 100644
--- a/addons/mrp/tests/test_order.py
+++ b/addons/mrp/tests/test_order.py
@@ -676,6 +676,32 @@ class TestMrpOrder(TestMrpCommon):
self.assertEqual(mo.move_raw_ids.filtered(lambda m: m.product_id == p1).quantity_done, 20, 'Update the produce quantity should not impact already produced quantity.')
mo.button_mark_done()
+ def test_product_produce_6(self):
+ """ Build some final products and change the quantity to produce
+ directly in the wizard. The component line should be updated .
+ """
+ self.stock_location = self.env.ref('stock.stock_location_stock')
+ mo, bom, p_final, p1, p2 = self.generate_mo(qty_final=1)
+ self.assertEqual(len(mo), 1, 'MO should have been created')
+
+ mo.action_assign()
+
+ produce_form = Form(self.env['mrp.product.produce'].with_context({
+ 'active_id': mo.id,
+ 'active_ids': [mo.id],
+ }))
+ produce_form.qty_producing = 3
+ self.assertEqual(len(produce_form.workorder_line_ids._records), 4, 'Update the produce quantity should change the components quantity.')
+ self.assertEqual(sum([x['qty_done'] for x in produce_form.workorder_line_ids._records]), 15, 'Update the produce quantity should change the components quantity.')
+ produce_form.qty_producing = 4
+ self.assertEqual(len(produce_form.workorder_line_ids._records), 6, 'Update the produce quantity should change the components quantity.')
+ self.assertEqual(sum([x['qty_done'] for x in produce_form.workorder_line_ids._records]), 20, 'Update the produce quantity should change the components quantity.')
+ produce_form.qty_producing = 1
+ self.assertEqual(len(produce_form.workorder_line_ids._records), 2, 'Update the produce quantity should change the components quantity.')
+ self.assertEqual(sum([x['qty_done'] for x in produce_form.workorder_line_ids._records]), 5, 'Update the produce quantity should change the components quantity.')
+ produce_wizard = produce_form.save()
+ produce_wizard.do_produce()
+
def test_product_produce_uom(self):
plastic_laminate = self.env.ref('mrp.product_product_plastic_laminate')
bom = self.env.ref('mrp.mrp_bom_plastic_laminate')
diff --git a/addons/mrp/views/mrp_unbuild_views.xml b/addons/mrp/views/mrp_unbuild_views.xml
index 4e20348b328..2505a183016 100644
--- a/addons/mrp/views/mrp_unbuild_views.xml
+++ b/addons/mrp/views/mrp_unbuild_views.xml
@@ -85,7 +85,7 @@
</div>
<div class="col-4">
<span class="float-right text-right">
- <field name="state" widget="kanban_label_selection" options="{'classes': {'draft': 'default', 'done': 'success'}}"/>
+ <field name="state" widget="label_selection" options="{'classes': {'draft': 'default', 'done': 'success'}}"/>
</span>
</div>
</div>
diff --git a/addons/mrp/views/stock_move_views.xml b/addons/mrp/views/stock_move_views.xml
index ef531c2cc45..46b52a10513 100644
--- a/addons/mrp/views/stock_move_views.xml
+++ b/addons/mrp/views/stock_move_views.xml
@@ -49,7 +49,7 @@
<field name="finished_lots_exist" invisible="1"/>
</group>
</group>
- <field name="active_move_line_ids" attrs="{'invisible': [('parent.state', '=', 'draft')], 'readonly': [('is_locked', '=', True)]}" context="{'default_workorder_id': workorder_id, 'default_product_uom_id': product_uom, 'default_product_id': product_id, 'default_location_id': location_id, 'default_location_dest_id': location_dest_id, 'default_production_id': production_id or raw_material_production_id}">
+ <field name="move_line_ids" attrs="{'invisible': [('parent.state', '=', 'draft')], 'readonly': [('is_locked', '=', True)]}" context="{'default_workorder_id': workorder_id, 'default_product_uom_id': product_uom, 'default_product_id': product_id, 'default_location_id': location_id, 'default_location_dest_id': location_dest_id, 'default_production_id': production_id or raw_material_production_id}">
<tree editable="bottom" decoration-success="product_uom_qty==qty_done" decoration-danger="(product_uom_qty &gt; 0) and (qty_done&gt;product_uom_qty)">
<field name="lot_id" attrs="{'column_invisible': [('parent.has_tracking', '=', 'none')]}" domain="[('product_id', '=', parent.product_id)]" context="{'default_product_id': parent.product_id}"/>
<field name="lot_produced_id" options="{'no_open': True, 'no_create': True}" domain="[('id', 'in', parent.order_finished_lot_ids)]" invisible="not context.get('final_lots')"/>
@@ -61,7 +61,6 @@
<field name="product_uom_id" invisible="1"/>
<field name="location_id" invisible="1"/>
<field name="location_dest_id" invisible="1"/>
- <field name="done_wo" invisible="1"/>
<field name="production_id" invisible="1"/>
</tree>
</field>
diff --git a/addons/payment_authorize/models/payment.py b/addons/payment_authorize/models/payment.py
index c1380db1fd6..5a6ed0bf8e1 100644
--- a/addons/payment_authorize/models/payment.py
+++ b/addons/payment_authorize/models/payment.py
@@ -6,6 +6,7 @@ from datetime import datetime
import hashlib
import hmac
import logging
+import string
import time
from odoo import _, api, fields, models
@@ -54,8 +55,16 @@ class PaymentAcquirerAuthorize(models.Model):
values['x_fp_sequence'],
values['x_fp_timestamp'],
values['x_amount'],
- values['x_currency_code']])
- return hmac.new(values['x_trans_key'].encode('utf-8'), data.encode('utf-8'), hashlib.md5).hexdigest()
+ values['x_currency_code']]).encode('utf-8')
+
+ # [BACKWARD COMPATIBILITY] Check that the merchant did update his transaction
+ # key to signature key (end of MD5 support from Authorize.net)
+ # The signature key is now '128-character hexadecimal format', while the
+ # transaction key was only 16-character.
+ if len(values['x_trans_key']) == 128:
+ return hmac.new(values['x_trans_key'].decode("hex").encode('utf-8'), data, hashlib.sha512).hexdigest().upper()
+ else:
+ return hmac.new(values['x_trans_key'].encode('utf-8'), data, hashlib.md5).hexdigest()
@api.multi
def authorize_form_generate_values(self, values):
@@ -172,7 +181,7 @@ class TxAuthorize(models.Model):
def _authorize_form_get_tx_from_data(self, data):
""" Given a data dict coming from authorize, verify it and find the related
transaction record. """
- reference, trans_id, fingerprint = data.get('x_invoice_num'), data.get('x_trans_id'), data.get('x_MD5_Hash')
+ reference, trans_id, fingerprint = data.get('x_invoice_num'), data.get('x_trans_id'), data.get('x_SHA2_Hash') or data.get('x_MD5_Hash')
if not reference or not trans_id or not fingerprint:
error_msg = _('Authorize: received data with missing reference (%s) or trans_id (%s) or fingerprint (%s)') % (reference, trans_id, fingerprint)
_logger.info(error_msg)
diff --git a/addons/payment_authorize/tests/test_authorize.py b/addons/payment_authorize/tests/test_authorize.py
index a45275b07d8..7e12b572419 100644
--- a/addons/payment_authorize/tests/test_authorize.py
+++ b/addons/payment_authorize/tests/test_authorize.py
@@ -1,7 +1,5 @@
# -*- coding: utf-8 -*-
-import hashlib
-import hmac
import time
from werkzeug import urls
from lxml import objectify
@@ -29,15 +27,6 @@ class AuthorizeCommon(PaymentAcquirerCommon):
@odoo.tests.tagged('post_install', '-at_install', '-standard', 'external')
class AuthorizeForm(AuthorizeCommon):
- def _authorize_generate_hashing(self, values):
- data = '^'.join([
- values['x_login'],
- values['x_fp_sequence'],
- values['x_fp_timestamp'],
- values['x_amount'],
- ]) + '^'
- return hmac.new(values['x_trans_key'].encode('utf-8'), data.encode('utf-8'), hashlib.md5).hexdigest()
-
def test_10_Authorize_form_render(self):
self.assertEqual(self.authorize.environment, 'test', 'test without test environment')
@@ -82,7 +71,7 @@ class AuthorizeForm(AuthorizeCommon):
'x_ship_to_state': None,
}
- form_values['x_fp_hash'] = self._authorize_generate_hashing(form_values)
+ form_values['x_fp_hash'] = self.env['payment.acquirer']._authorize_generate_hashing(form_values)
# render the button
res = self.authorize.render('SO004', 56.16, self.currency_usd.id, values=self.buyer_values)
# check form result
@@ -109,7 +98,9 @@ class AuthorizeForm(AuthorizeCommon):
# typical data posted by authorize after client has successfully paid
authorize_post_data = {
'return_url': u'/shop/payment/validate',
+ # x_MD5_Hash will be empty starting the 28th March 2019
'x_MD5_Hash': u'7934485E1C105940BE854208D10FAB4F',
+ 'x_SHA2_Hash': u'7D3AC844BE8CA3F649AB885A90D22CFE35B850338EC91D1A5ADD819A85FF948A3D777334A18CDE36821DC8F2B42A6E1950C1FF96B52B60F23201483A656195FB',
'x_account_number': u'XXXX0027',
'x_address': u'Huge Street 2/543',
'x_amount': u'320.00',
diff --git a/addons/payment_payulatam/models/payment.py b/addons/payment_payulatam/models/payment.py
index 8389f6eb42e..df487543c45 100644
--- a/addons/payment_payulatam/models/payment.py
+++ b/addons/payment_payulatam/models/payment.py
@@ -82,7 +82,7 @@ class PaymentTransactionPayulatam(models.Model):
if not reference or not txnid or not sign:
raise ValidationError(_('PayUlatam: received data with missing reference (%s) or transaction id (%s) or sign (%s)') % (reference, txnid, sign))
- transaction = self.search([('acquirer_reference', '=', reference)])
+ transaction = self.search([('reference', '=', reference)])
if not transaction:
error_msg = (_('PayUlatam: received data for reference %s; no order found') % (reference))
diff --git a/addons/payment_payulatam/tests/test_payulatam.py b/addons/payment_payulatam/tests/test_payulatam.py
index 2bec371b81a..10914a98308 100644
--- a/addons/payment_payulatam/tests/test_payulatam.py
+++ b/addons/payment_payulatam/tests/test_payulatam.py
@@ -121,10 +121,9 @@ class PayUlatamForm(PayUlatamCommon):
'amount': 0.01,
'acquirer_id': self.payulatam.id,
'currency_id': self.currency_euro.id,
- 'reference': 'test_ref_2',
+ 'reference': 'test_ref_10',
'partner_name': 'Norbert Buyer',
'partner_country_id': self.country_france.id,
- 'acquirer_reference': 'test_ref_10',
'partner_id': self.buyer_id})
# validate transaction
diff --git a/addons/point_of_sale/static/src/css/pos.css b/addons/point_of_sale/static/src/css/pos.css
index ed137077f64..8fefe02fa09 100644
--- a/addons/point_of_sale/static/src/css/pos.css
+++ b/addons/point_of_sale/static/src/css/pos.css
@@ -1479,6 +1479,8 @@ td {
body {
margin: 0;
color: white !important;
+ /* avoid black background if backgrounds are printed */
+ background: initial;
}
.oe_leftbar,
.oe_loading,
diff --git a/addons/point_of_sale/static/src/js/screens.js b/addons/point_of_sale/static/src/js/screens.js
index 56e460c4a85..1d08da927ea 100644
--- a/addons/point_of_sale/static/src/js/screens.js
+++ b/addons/point_of_sale/static/src/js/screens.js
@@ -1326,7 +1326,7 @@ var ClientListScreenWidget = ScreenWidget.extend({
self.save_client_details(partner);
});
};
- this.reload_partners().then( function() {
+ return this.reload_partners().then( function() {
var partner = self.pos.db.get_partner_by_id(partner_id);
if (partner) {
self.new_client = partner;
diff --git a/addons/point_of_sale/views/pos_order_view.xml b/addons/point_of_sale/views/pos_order_view.xml
index 18e6a3ec53e..6bccce47ce5 100644
--- a/addons/point_of_sale/views/pos_order_view.xml
+++ b/addons/point_of_sale/views/pos_order_view.xml
@@ -146,7 +146,7 @@
</div>
<div class="col-4">
<span class="float-right text-right">
- <field name="state" widget="kanban_label_selection" options="{'classes': {'draft': 'default',
+ <field name="state" widget="label_selection" options="{'classes': {'draft': 'default',
'invoiced': 'success', 'cancel': 'danger'}}"/>
</span>
</div>
diff --git a/addons/point_of_sale/views/pos_session_view.xml b/addons/point_of_sale/views/pos_session_view.xml
index 27e32ac66ac..1aa738860e0 100644
--- a/addons/point_of_sale/views/pos_session_view.xml
+++ b/addons/point_of_sale/views/pos_session_view.xml
@@ -164,7 +164,7 @@
<div class="o_kanban_record_headings">
<strong class="o_kanban_record_title"><span><field name="config_id"/></span></strong>
</div>
- <field name="state" widget="kanban_label_selection" options="{'classes': {'opening_control': 'default',
+ <field name="state" widget="label_selection" options="{'classes': {'opening_control': 'default',
'opened': 'success', 'closing_control': 'warning', 'closed': 'warning'}}"/>
</div>
<div class="o_kanban_record_body">
diff --git a/addons/portal/controllers/portal.py b/addons/portal/controllers/portal.py
index fbfa39ebe89..2efa54b5c17 100644
--- a/addons/portal/controllers/portal.py
+++ b/addons/portal/controllers/portal.py
@@ -55,6 +55,10 @@ def pager(url, total, page=1, step=30, scope=5, url_args=None):
'url': get_url(page),
'num': page
},
+ "page_first": {
+ 'url': get_url(1),
+ 'num': 1
+ },
"page_start": {
'url': get_url(pmin),
'num': pmin
@@ -71,6 +75,10 @@ def pager(url, total, page=1, step=30, scope=5, url_args=None):
'url': get_url(pmax),
'num': pmax
},
+ "page_last": {
+ 'url': get_url(page_count),
+ 'num': page_count
+ },
"pages": [
{'url': get_url(page_num), 'num': page_num} for page_num in range(pmin, pmax+1)
]
diff --git a/addons/portal/wizard/portal_share.py b/addons/portal/wizard/portal_share.py
index 8474f2c810c..0da5ce44a7b 100644
--- a/addons/portal/wizard/portal_share.py
+++ b/addons/portal/wizard/portal_share.py
@@ -10,9 +10,9 @@ class PortalShare(models.TransientModel):
@api.model
def default_get(self, fields):
result = super(PortalShare, self).default_get(fields)
- record = self.env[result['res_model']].browse(result['res_id'])
result['res_model'] = self._context.get('active_model')
result['res_id'] = self._context.get('active_id')
+ record = self.env[result['res_model']].browse(result['res_id'])
result['share_link'] = record.get_base_url() + record._get_share_url(redirect=True)
return result
diff --git a/addons/portal/wizard/portal_wizard.py b/addons/portal/wizard/portal_wizard.py
index ee5b82eecda..2f17441f451 100644
--- a/addons/portal/wizard/portal_wizard.py
+++ b/addons/portal/wizard/portal_wizard.py
@@ -35,7 +35,7 @@ class PortalWizard(models.TransientModel):
contact_ids = set()
user_changes = []
for partner in self.env['res.partner'].sudo().browse(partner_ids):
- contact_partners = partner.child_ids or [partner]
+ contact_partners = partner.child_ids | partner
for contact in contact_partners:
# make sure that each contact appears at most once in the list
if contact.id not in contact_ids:
diff --git a/addons/product/models/product.py b/addons/product/models/product.py
index 24c7db102a9..d1f95da76b5 100644
--- a/addons/product/models/product.py
+++ b/addons/product/models/product.py
@@ -301,6 +301,8 @@ class ProductProduct(models.Model):
# When a unique variant is created from tmpl then the standard price is set by _set_standard_price
if not (self.env.context.get('create_from_tmpl') and len(product.product_tmpl_id.product_variant_ids) == 1):
product._set_standard_price(vals.get('standard_price') or 0.0)
+ # `_get_variant_id_for_combination` depends on existing variants
+ self.clear_caches()
return products
@api.multi
@@ -309,6 +311,13 @@ class ProductProduct(models.Model):
res = super(ProductProduct, self).write(values)
if 'standard_price' in values:
self._set_standard_price(values['standard_price'])
+ if 'attribute_value_ids' in values:
+ # `_get_variant_id_for_combination` depends on `attribute_value_ids`
+ self.clear_caches()
+ if 'active' in values:
+ # prefetched o2m have to be reloaded (because of active_test)
+ # (eg. product.template: product_variant_ids)
+ self.invalidate_cache()
return res
@api.multi
@@ -329,6 +338,8 @@ class ProductProduct(models.Model):
# delete templates after calling super, as deleting template could lead to deleting
# products due to ondelete='cascade'
unlink_templates.unlink()
+ # `_get_variant_id_for_combination` depends on existing variants
+ self.clear_caches()
return res
@api.multi
@@ -617,6 +628,10 @@ class ProductProduct(models.Model):
We must make sure that all attributes are used to take into account the case where
attributes would be added to the template.
+ This method does not check if the combination is possible, it just
+ checks if it has valid attributes and values. A possible combination
+ is always valid, but a valid combination is not always possible.
+
:param valid_attributes: a recordset of product.attribute
:param valid_values: a recordset of product.attribute.value
:return: True if the attibutes and values are correct, False instead
diff --git a/addons/product/models/product_attribute.py b/addons/product/models/product_attribute.py
index ed1f70b4bc4..32f86b79eea 100644
--- a/addons/product/models/product_attribute.py
+++ b/addons/product/models/product_attribute.py
@@ -44,7 +44,13 @@ class ProductAttribute(models.Model):
if products:
message = ', '.join(products.mapped('name'))
raise UserError(_('You are trying to change the type of an attribute value still referenced on at least one product template: %s') % message)
- return super(ProductAttribute, self).write(vals)
+ invalidate_cache = 'sequence' in vals and any(record.sequence != vals['sequence'] for record in self)
+ res = super(ProductAttribute, self).write(vals)
+ if invalidate_cache:
+ # prefetched o2m have to be resequenced
+ # (eg. product.template: attribute_line_ids)
+ self.invalidate_cache()
+ return res
@api.multi
def _get_related_product_templates(self):
@@ -79,6 +85,16 @@ class ProductAttributeValue(models.Model):
return ", ".join([v.name for v in self if v.attribute_id in variable_attributes])
@api.multi
+ def write(self, values):
+ invalidate_cache = 'sequence' in values and any(record.sequence != values['sequence'] for record in self)
+ res = super(ProductAttributeValue, self).write(values)
+ if invalidate_cache:
+ # prefetched o2m have to be resequenced
+ # (eg. product.template.attribute.line: value_ids)
+ self.invalidate_cache()
+ return res
+
+ @api.multi
def unlink(self):
linked_products = self._get_related_product_templates()
if linked_products:
@@ -136,8 +152,8 @@ class ProductTemplateAttributeLine(models.Model):
for product_template_attribute_line in self:
product_template_attribute_line.product_template_value_ids = self.env['product.template.attribute.value'].search([
('product_tmpl_id', 'in', product_template_attribute_line.product_tmpl_id.ids),
- ('product_attribute_value_id.attribute_id', 'in', product_template_attribute_line.value_ids.mapped('attribute_id').ids)]
- ).sorted(lambda product_product_attribute: product_product_attribute.sequence)
+ ('product_attribute_value_id', 'in', product_template_attribute_line.value_ids.ids)]
+ )
@api.multi
def unlink(self):
@@ -246,9 +262,9 @@ class ProductTemplateAttributeExclusion(models.Model):
_description = 'Product Template Attribute Exclusion'
product_template_attribute_value_id = fields.Many2one(
- 'product.template.attribute.value', string="Attribute Value", ondelete='cascade')
+ 'product.template.attribute.value', string="Attribute Value", ondelete='cascade', index=True)
product_tmpl_id = fields.Many2one(
- 'product.template', string='Product Template', ondelete='cascade', required=True)
+ 'product.template', string='Product Template', ondelete='cascade', required=True, index=True)
value_ids = fields.Many2many(
'product.template.attribute.value', relation="product_attr_exclusion_value_ids_rel",
string='Attribute Values', domain="[('product_tmpl_id', '=', product_tmpl_id)]")
diff --git a/addons/product/models/product_pricelist.py b/addons/product/models/product_pricelist.py
index 4cae61d9a0b..5f40c360d23 100644
--- a/addons/product/models/product_pricelist.py
+++ b/addons/product/models/product_pricelist.py
@@ -246,7 +246,11 @@ class Pricelist(models.Model):
break
# Final price conversion into pricelist currency
if suitable_rule and suitable_rule.compute_price != 'fixed' and suitable_rule.base != 'pricelist':
- price = product.currency_id._convert(price, self.currency_id, self.env.user.company_id, date, round=False)
+ if suitable_rule.base == 'standard_price':
+ cur = product.cost_currency_id
+ else:
+ cur = product.currency_id
+ price = cur._convert(price, self.currency_id, self.env.user.company_id, date, round=False)
results[product.id] = (price, suitable_rule and suitable_rule.id or False)
@@ -494,3 +498,11 @@ class PricelistItem(models.Model):
'price_min_margin': 0.0,
'price_max_margin': 0.0,
})
+
+ @api.multi
+ def write(self, values):
+ res = super(PricelistItem, self).write(values)
+ # When the pricelist changes we need the product.template price
+ # to be invalided and recomputed.
+ self.invalidate_cache()
+ return res
diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py
index 26a827cb9f1..56e817135ca 100644
--- a/addons/product/models/product_template.py
+++ b/addons/product/models/product_template.py
@@ -62,6 +62,8 @@ class ProductTemplate(models.Model):
currency_id = fields.Many2one(
'res.currency', 'Currency', compute='_compute_currency_id')
+ cost_currency_id = fields.Many2one(
+ 'res.currency', 'Cost Currency', compute='_compute_cost_currency_id')
# price fields
# price: total template price, context dependent (partner, pricelist, quantity)
@@ -119,6 +121,25 @@ class ProductTemplate(models.Model):
is_product_variant = fields.Boolean(string='Is a product variant', compute='_compute_is_product_variant')
attribute_line_ids = fields.One2many('product.template.attribute.line', 'product_tmpl_id', 'Product Attributes')
+
+ valid_product_template_attribute_line_ids = fields.Many2many('product.template.attribute.line',
+ compute="_compute_valid_attributes", string='Valid Product Attribute Lines', help="Technical compute")
+ valid_product_attribute_value_ids = fields.Many2many('product.attribute.value',
+ compute="_compute_valid_attributes", string='Valid Product Attribute Values', help="Technical compute")
+ valid_product_attribute_ids = fields.Many2many('product.attribute',
+ compute="_compute_valid_attributes", string='Valid Product Attributes', help="Technical compute")
+ # wnva = without no_variant attributes
+ valid_product_template_attribute_line_wnva_ids = fields.Many2many('product.template.attribute.line',
+ compute="_compute_valid_attributes", string='Valid Product Attribute Lines Without No Variant Attributes', help="Technical compute")
+ valid_product_attribute_value_wnva_ids = fields.Many2many('product.attribute.value',
+ compute="_compute_valid_attributes", string='Valid Product Attribute Values Without No Variant Attributes', help="Technical compute")
+ valid_product_attribute_wnva_ids = fields.Many2many('product.attribute',
+ compute="_compute_valid_attributes", string='Valid Product Attributes Without No Variant Attributes', help="Technical compute")
+ valid_archived_variant_ids = fields.Many2many('product.product',
+ compute="_compute_valid_attributes", string='Valid Archived Variants', help="Technical compute")
+ valid_existing_variant_ids = fields.Many2many('product.product',
+ compute="_compute_valid_existing_variant_ids", string='Valid Existing Variants', help="Technical compute")
+
product_variant_ids = fields.One2many('product.product', 'product_tmpl_id', 'Products', required=True)
# performance: product_variant_id provides prefetching on the first product variant only
product_variant_id = fields.Many2one('product.product', 'Product', compute='_compute_product_variant_id')
@@ -145,6 +166,10 @@ class ProductTemplate(models.Model):
for template in self:
template.currency_id = template.company_id.sudo().currency_id.id or main_company.currency_id.id
+ def _compute_cost_currency_id(self):
+ for template in self:
+ template.cost_currency_id = self.env.user.company_id.currency_id.id
+
@api.multi
def _compute_template_price(self):
prices = self._compute_template_price_no_inverse()
@@ -464,7 +489,7 @@ class ProductTemplate(models.Model):
# Iterator containing all possible `product.attribute.value` combination
# The iterator is used to avoid MemoryError in case of a huge number of combination.
all_variants = itertools.product(*(
- line.value_ids.ids for line in tmpl_id._get_valid_product_template_attribute_lines()._without_no_variant_attributes()
+ line.value_ids.ids for line in tmpl_id.valid_product_template_attribute_line_wnva_ids
))
# Set containing existing `product.attribute.value` combination
existing_variants = {
@@ -490,8 +515,8 @@ class ProductTemplate(models.Model):
# should be activated
# - if the product does not have valid attributes or attribute values, it should be
# deleted
- valid_value_ids = tmpl_id._get_valid_product_attribute_values()._without_no_variant_attributes()
- valid_attribute_ids = tmpl_id._get_valid_product_attributes()._without_no_variant_attributes()
+ valid_value_ids = tmpl_id.valid_product_attribute_value_wnva_ids
+ valid_attribute_ids = tmpl_id.valid_product_attribute_wnva_ids
for product_id in tmpl_id.product_variant_ids:
if product_id._has_valid_attributes(valid_attribute_ids, valid_value_ids):
if not product_id.active:
@@ -523,6 +548,10 @@ class ProductTemplate(models.Model):
# This is the case from existing stock reordering rules.
variant.write({'active': False})
+ # prefetched o2m have to be reloaded (because of active_test)
+ # (eg. product.template: product_variant_ids)
+ # We can't rely on existing invalidate_cache because of the savepoint.
+ self.invalidate_cache()
return True
def has_dynamic_attributes(self):
@@ -536,7 +565,7 @@ class ProductTemplate(models.Model):
return any(a.create_variant == 'dynamic' for a in self._get_valid_product_attributes())
@api.multi
- def _get_valid_product_template_attribute_lines(self):
+ def _compute_valid_attributes(self):
"""A product template attribute line is considered valid if it has at
least one possible value.
@@ -545,42 +574,68 @@ class ProductTemplate(models.Model):
value to input), indeed single value attributes can be used to filter
products among others based on that attribute/value.
- This method is necessary because it was previously possible to save a
- line without any value on it, so the database might not be consistent in
- that regard.
+ A product attribute value is considered valid for a template if it is
+ defined on a product template attribute line.
+
+ A product attribute is considered valid for a template if it
+ has at least one possible value set on the template.
- :return: all the valid product template attribute lines of this template
- :rtype: recordset `product.template.attribute.line`
+ For what is considered an archived variant, see `_has_valid_attributes`.
"""
- self.ensure_one()
- return self.attribute_line_ids.filtered(lambda ptal: ptal.value_ids)
+ archived_variants = self.env['product.product'].search([('product_tmpl_id', 'in', self.ids), ('active', '=', False)])
+
+ # prefetch
+ self.mapped('attribute_line_ids.value_ids.id')
+ self.mapped('attribute_line_ids.attribute_id.create_variant')
+
+ for record in self:
+ record.valid_product_template_attribute_line_ids = record.attribute_line_ids.filtered(lambda ptal: ptal.value_ids)
+ record.valid_product_template_attribute_line_wnva_ids = record.valid_product_template_attribute_line_ids._without_no_variant_attributes()
+
+ record.valid_product_attribute_value_ids = record.valid_product_template_attribute_line_ids.mapped('value_ids')
+ record.valid_product_attribute_value_wnva_ids = record.valid_product_template_attribute_line_wnva_ids.mapped('value_ids')
+
+ record.valid_product_attribute_ids = record.valid_product_template_attribute_line_ids.mapped('attribute_id')
+ record.valid_product_attribute_wnva_ids = record.valid_product_template_attribute_line_wnva_ids.mapped('attribute_id')
+
+ valid_value_ids = record.valid_product_attribute_value_wnva_ids
+ valid_attribute_ids = record.valid_product_attribute_wnva_ids
+
+ record.valid_archived_variant_ids = archived_variants.filtered(
+ lambda v: v.product_tmpl_id == record and v._has_valid_attributes(valid_attribute_ids, valid_value_ids)
+ )
@api.multi
- def _get_valid_product_attributes(self):
- """A product attribute is considered valid for a template if it
- has at least one possible value set on the template.
+ def _compute_valid_existing_variant_ids(self):
+ """This compute is done outside of `_compute_valid_attributes` because
+ it is often not needed, and it can be very bad on performance."""
+ existing_variants = self.env['product.product'].search([('product_tmpl_id', 'in', self.ids), ('active', '=', True)])
+ for record in self:
- See `_get_valid_product_template_attribute_lines`.
+ valid_value_ids = record.valid_product_attribute_value_wnva_ids
+ valid_attribute_ids = record.valid_product_attribute_wnva_ids
- :return: all the valid product attributes of this template
- :rtype: recordset `product.attribute`
- """
+ record.valid_existing_variant_ids = existing_variants.filtered(
+ lambda v: v.product_tmpl_id == record and v._has_valid_attributes(valid_attribute_ids, valid_value_ids)
+ )
+
+ @api.multi
+ def _get_valid_product_template_attribute_lines(self):
+ """deprecated, use `valid_product_template_attribute_line_ids`"""
self.ensure_one()
- product_attributes = self.env['product.attribute']
- for ptal in self._get_valid_product_template_attribute_lines():
- product_attributes |= ptal.attribute_id
- return product_attributes
+ return self.valid_product_template_attribute_line_ids
@api.multi
- def _get_valid_product_attribute_values(self):
- """A product attribute value is considered valid for a template if it is
- defined on a product template attribute line.
+ def _get_valid_product_attributes(self):
+ """deprecated, use `valid_product_attribute_ids`"""
+ self.ensure_one()
+ return self.valid_product_attribute_ids
- :return: all the valid product attribute values of this template
- :rtype: recordset `product.attribute.value`
- """
+ @api.multi
+ def _get_valid_product_attribute_values(self):
+ """deprecated, use `valid_product_attribute_value_ids`"""
self.ensure_one()
- return self._get_valid_product_template_attribute_lines().mapped('value_ids')
+ return self.valid_product_attribute_value_ids
@api.multi
def _get_possible_variants(self, parent_combination=None):
@@ -589,6 +644,13 @@ class ProductTemplate(models.Model):
For dynamic attributes, it will only return the variants that have been
created already. For no_variant attributes, it will return an empty
recordset because the variants themselves are not a full combination.
+ If there are a lot of variants, this method might be slow. Even if there
+ aren't too many variants, for performance reasons, do not call this
+ method in a loop over the product templates.
+
+ Therefore this method has a very restricted reasonable use case and you
+ should strongly consider doing things differently if you consider using
+ this method.
:param parent_combination: combination from which `self` is an
optional or accessory product.
@@ -688,19 +750,8 @@ class ProductTemplate(models.Model):
Array, each element is an array with ids of an archived combination.
"""
- valid_value_ids = self._get_valid_product_attribute_values()._without_no_variant_attributes()
- valid_attribute_ids = self._get_valid_product_attributes()._without_no_variant_attributes()
-
- # Search only among those having the right set of attributes.
- domain = [('product_tmpl_id', '=', self.id), ('active', '=', False)]
- for pa in valid_attribute_ids:
- domain = expression.AND([[('attribute_value_ids.attribute_id.id', '=', pa.id)], domain])
- archived_variants = self.env['product.product'].search(domain)
-
- archived_variants = archived_variants.filtered(lambda v: v._has_valid_attributes(valid_attribute_ids, valid_value_ids))
-
return [archived_variant.product_template_attribute_value_ids.ids
- for archived_variant in archived_variants]
+ for archived_variant in self.valid_archived_variant_ids]
@api.multi
def _get_existing_combinations(self):
@@ -712,19 +763,8 @@ class ProductTemplate(models.Model):
Array, each element is an array with ids of an existing combination.
"""
- valid_value_ids = self._get_valid_product_attribute_values()._without_no_variant_attributes()
- valid_attribute_ids = self._get_valid_product_attributes()._without_no_variant_attributes()
-
- # Search only among those having the right set of attributes.
- domain = [('product_tmpl_id', '=', self.id), ('active', '=', True)]
- for pa in valid_attribute_ids:
- domain = expression.AND([[('attribute_value_ids.attribute_id.id', '=', pa.id)], domain])
- existing_variants = self.env['product.product'].search(domain)
-
- existing_variants = existing_variants.filtered(lambda v: v._has_valid_attributes(valid_attribute_ids, valid_value_ids))
-
return [variant.product_template_attribute_value_ids.ids
- for variant in existing_variants]
+ for variant in self.valid_existing_variant_ids]
@api.multi
def _get_no_variant_product_template_attribute_values(self):
@@ -760,17 +800,20 @@ class ProductTemplate(models.Model):
"""
self.ensure_one()
- if len(combination) != len(self._get_valid_product_template_attribute_lines()):
+ if len(combination) != len(self.valid_product_template_attribute_line_ids):
# number of attribute values passed is different than the
# configuration of attributes on the template
return False
- if self._get_valid_product_attributes() != combination.mapped('attribute_id'):
+ if self.valid_product_attribute_ids != combination.mapped('attribute_id'):
# combination has different attributes than the ones configured on the template
return False
- variant = self._get_variant_for_combination(combination)
- if not self.has_dynamic_attributes() and not variant:
+ if self.valid_product_attribute_value_ids < combination.mapped('product_attribute_value_id'):
+ # combination has different values than the ones configured on the template
+ return False
+
+ if not self.has_dynamic_attributes() and not self._get_variant_for_combination(combination):
# the variant has been deleted
return False
@@ -812,40 +855,64 @@ class ProductTemplate(models.Model):
self.ensure_one()
filtered_combination = combination._without_no_variant_attributes()
+ attribute_values = filtered_combination.mapped('product_attribute_value_id')
+ return self.env['product.product'].browse(self._get_variant_id_for_combination(attribute_values))
+ @api.multi
+ @tools.ormcache('self', 'attribute_values')
+ def _get_variant_id_for_combination(self, attribute_values):
+ """See `_get_variant_for_combination`. This method returns an ID
+ so it can be cached."""
+ self.ensure_one()
# If there are a lot of variants on this template, it is much faster to
# build a query than using the existing o2m.
domain = [('product_tmpl_id', '=', self.id)]
- for ptav in filtered_combination:
- domain = expression.AND([[('attribute_value_ids.id', '=', ptav.product_attribute_value_id.id)], domain])
- res = self.env['product.product'].search(domain)
+ for pav in attribute_values:
+ domain = expression.AND([[('attribute_value_ids', 'in', pav.id)], domain])
+
+ res = self.env['product.product'].with_context(active_test=False).search(domain)
# The domain above is checking for the `product.attribute.value`, but we
# need to make sure it's the same `product.template.attribute.value`.
# Also there should theorically be only 0 or 1 but an existing database
# might not be consistent so we need to make sure to take max 1.
return res.filtered(
- lambda v: v.product_template_attribute_value_ids == filtered_combination
- )[:1]
+ lambda v: v.attribute_value_ids == attribute_values
+ )[:1].id
@api.multi
- def _get_first_possible_combination(self, parent_combination=None, necessary_values=None):
- """
- Iterate the attributes and values in order and stop at the first
- combination of values that is possible.
-
- When encountering an impossible combination, try to change the
- value of latest attributes first.
+ @tools.ormcache('self')
+ def _get_first_possible_variant_id(self):
+ """See `_create_first_product_variant`. This method returns an ID
+ so it can be cached."""
+ self.ensure_one()
+ return self._create_first_product_variant().id
- Ignore attributes that have no values.
+ @api.multi
+ def _get_first_possible_combination(self, parent_combination=None, necessary_values=None):
+ """See `_get_possible_combinations` (one iteration).
- Note this method return the same result (empty recordset) if no
+ This method return the same result (empty recordset) if no
combination is possible at all which would be considered a negative
result, or if there are no attribute lines on the template in which
case the "empty combination" is actually a possible combination.
Therefore the result of this method when empty should be tested
with `_is_combination_possible` if it's important to know if the
resulting empty combination is actually possible or not.
+ """
+ return next(self._get_possible_combinations(parent_combination, necessary_values), self.env['product.template.attribute.value'])
+
+ @api.multi
+ def _get_possible_combinations(self, parent_combination=None, necessary_values=None):
+ """Generator returning combinations that are possible, following the
+ sequence of attributes and values.
+
+ See `_is_combination_possible` for what is a possible combination.
+
+ When encountering an impossible combination, try to change the value
+ of attributes by starting with the further regarding their sequences.
+
+ Ignore attributes that have no values.
:param parent_combination: combination from which `self` is an
optional or accessory product.
@@ -854,63 +921,81 @@ class ProductTemplate(models.Model):
:param necessary_values: values that must be in the returned combination
:type necessary_values: recordset of `product.template.attribute.value`
- :return: the first possible combination found, or empty if none possible
- :rtype: recordset of `product.template.attribute.value`
+ :return: the possible combinations
+ :rtype: generator of recordset of `product.template.attribute.value`
"""
self.ensure_one()
if not self.active:
- return self.env['product.template.attribute.value']
+ return _("The product template is archived so no combination is possible.")
- attribute_lines = self._get_valid_product_template_attribute_lines()
+ ptal_stack = [self._get_valid_product_template_attribute_lines()]
+ combination_stack = [self.env['product.template.attribute.value']]
- def iterate_attribute_lines(attribute_lines, combination):
- """
- :param attribute_lines: recordset of product.template.attribute.line
- that are still to iterate
- :param combination: recordset of product.template.attribute.value
- that have to be tested for possibility
+ # keep going while we have attribute lines to test
+ while len(ptal_stack):
+ attribute_lines = ptal_stack.pop()
+ combination = combination_stack.pop()
- :return: the first possible combination found, or empty
- :rtype: recordset of `product.template.attribute.value`
- """
if not attribute_lines:
- if self._is_combination_possible(combination, parent_combination):
- return combination
- else:
- return self.env['product.template.attribute.value']
- for cur in attribute_lines[0].product_template_value_ids:
- res = iterate_attribute_lines(attribute_lines[1:], combination + cur)
- if res and all(v in res for v in (necessary_values or [])):
- return res
- return self.env['product.template.attribute.value']
+ # full combination, if it's possible return it, otherwise skip it
+ if self._is_combination_possible(combination, parent_combination) and all(v in combination for v in (necessary_values or [])):
+ yield(combination)
+ else:
+ # we have remaining attribute lines to consider
+ for ptav in reversed(attribute_lines[0].product_template_value_ids):
+ ptal_stack.append(attribute_lines[1:])
+ combination_stack.append(combination + ptav)
- return iterate_attribute_lines(attribute_lines, self.env['product.template.attribute.value'])
+ return _("There are no remaining possible combination.")
@api.multi
def _get_closest_possible_combination(self, combination):
- """Get the first possible combination that is the closest to the given
- combination.
+ """See `_get_closest_possible_combinations` (one iteration).
+
+ This method return the same result (empty recordset) if no
+ combination is possible at all which would be considered a negative
+ result, or if there are no attribute lines on the template in which
+ case the "empty combination" is actually a possible combination.
+ Therefore the result of this method when empty should be tested
+ with `_is_combination_possible` if it's important to know if the
+ resulting empty combination is actually possible or not.
+ """
+ return next(self._get_closest_possible_combinations(combination), self.env['product.template.attribute.value'])
+
+ @api.multi
+ def _get_closest_possible_combinations(self, combination):
+ """Generator returning the possible combinations that are the closest to
+ the given combination.
If the given combination is incomplete, try to complete it.
If the given combination is invalid, try to remove values from it before
completing it.
- See `_get_first_possible_combination` note about empty result.
-
:param combination: the values to include if they are possible
:type combination: recordset `product.template.attribute.value`
- :return: the first possible combination that is including as much
+ :return: the possible combinations that are including as much
elements as possible from the given combination.
- :rtype: recordset of product.template.attribute.value
+ :rtype: generator of recordset of product.template.attribute.value
"""
while True:
- result = self._get_first_possible_combination(necessary_values=combination)
- if result or not combination:
- return result
- combination = combination[:-1]
+ res = self._get_possible_combinations(necessary_values=combination)
+ try:
+ # If there is at least one result for the given combination
+ # we consider that combination set, and we yield all the
+ # possible combinations for it.
+ yield(next(res))
+ for cur in res:
+ yield(cur)
+ return _("There are no remaining closest combination.")
+ except StopIteration:
+ # There are no results for the given combination, we try to
+ # progressively remove values from it.
+ if not combination:
+ return _("There are no possible combination.")
+ combination = combination[:-1]
@api.multi
def _get_current_company(self, **kwargs):
diff --git a/addons/product/tests/test_product_attribute_value_config.py b/addons/product/tests/test_product_attribute_value_config.py
index 19070a1c887..aa1de67d16b 100644
--- a/addons/product/tests/test_product_attribute_value_config.py
+++ b/addons/product/tests/test_product_attribute_value_config.py
@@ -204,8 +204,8 @@ class TestProductAttributeValueConfig(TestProductAttributeValueSetup):
self.assertEqual(len(variant), 0)
# also test _has_valid_attributes (case ok):
- valid_value_ids = self.computer._get_valid_product_attribute_values()._without_no_variant_attributes()
- valid_attribute_ids = self.computer._get_valid_product_attributes()._without_no_variant_attributes()
+ valid_value_ids = self.computer.valid_product_attribute_value_wnva_ids
+ valid_attribute_ids = self.computer.valid_product_attribute_wnva_ids
self.assertTrue(ok_variant._has_valid_attributes(valid_attribute_ids, valid_value_ids))
# also test _has_valid_attributes (case not ok):
@@ -321,34 +321,52 @@ class TestProductAttributeValueConfig(TestProductAttributeValueSetup):
computer_hdd_4 = self._get_product_template_attribute_value(self.hdd_4)
self._add_exclude(computer_ram_16, computer_hdd_1)
- # ram_8 is allowed with the other attributes
- self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_256 + computer_ram_8 + computer_hdd_1)
-
- # Below invalidate cache between every test to force reload the
- # o2m attribute_line_ids to order it by the new sequence
+ # Basic case: test all iterations of generator
+ gen = self.computer._get_possible_combinations()
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_8 + computer_hdd_1)
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_8 + computer_hdd_2)
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_8 + computer_hdd_4)
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_16 + computer_hdd_2)
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_16 + computer_hdd_4)
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_32 + computer_hdd_1)
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_32 + computer_hdd_2)
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_32 + computer_hdd_4)
+ self.assertEqual(next(gen), computer_ssd_512 + computer_ram_8 + computer_hdd_1)
+ self.assertEqual(next(gen), computer_ssd_512 + computer_ram_8 + computer_hdd_2)
+ self.assertEqual(next(gen), computer_ssd_512 + computer_ram_8 + computer_hdd_4)
+ self.assertEqual(next(gen), computer_ssd_512 + computer_ram_16 + computer_hdd_2)
+ self.assertEqual(next(gen), computer_ssd_512 + computer_ram_16 + computer_hdd_4)
+ self.assertEqual(next(gen), computer_ssd_512 + computer_ram_32 + computer_hdd_1)
+ self.assertEqual(next(gen), computer_ssd_512 + computer_ram_32 + computer_hdd_2)
+ self.assertEqual(next(gen), computer_ssd_512 + computer_ram_32 + computer_hdd_4)
+ with self.assertRaises(StopIteration):
+ next(gen)
# Give priority to ram_16 but it is not allowed by hdd_1 so it should return hhd_2 instead
+ # Test invalidate_cache on product.attribute.value write
computer_ram_16.product_attribute_value_id.sequence = -1
- self.computer.invalidate_cache() # need o2m to be reordered
self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_256 + computer_ram_16 + computer_hdd_2)
+ # Move down the ram, so it will try to change the ram instead of the hdd
+ # Test invalidate_cache on product.attribute write
+ self.ram_attribute.sequence = 10
+ self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_256 + computer_ram_8 + computer_hdd_1)
+
# Give priority to ram_32 and is allowed with the rest so it should return it
+ self.ram_attribute.sequence = 2
computer_ram_16.product_attribute_value_id.sequence = 2
computer_ram_32.product_attribute_value_id.sequence = -1
- self.computer.invalidate_cache() # need o2m to be reordered
self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_256 + computer_ram_32 + computer_hdd_1)
# Give priority to ram_16 but now it is not allowing any hdd so it should return ram_8 instead
computer_ram_32.product_attribute_value_id.sequence = 3
computer_ram_16.product_attribute_value_id.sequence = -1
- self.computer.invalidate_cache() # need o2m to be reordered
self._add_exclude(computer_ram_16, computer_hdd_2)
self._add_exclude(computer_ram_16, computer_hdd_4)
self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_256 + computer_ram_8 + computer_hdd_1)
# Only the last combination is possible
computer_ram_16.product_attribute_value_id.sequence = 2
- self.computer.invalidate_cache() # need o2m to be reordered
self._add_exclude(computer_ram_8, computer_hdd_1)
self._add_exclude(computer_ram_8, computer_hdd_2)
self._add_exclude(computer_ram_8, computer_hdd_4)
@@ -357,26 +375,37 @@ class TestProductAttributeValueConfig(TestProductAttributeValueSetup):
self._add_exclude(computer_ram_32, computer_ssd_256)
self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_512 + computer_ram_32 + computer_hdd_4)
- # No possible combination
+ # No possible combination (test helper and iterator)
self._add_exclude(computer_ram_32, computer_hdd_4)
self.assertEqual(self.computer._get_first_possible_combination(), self.env['product.template.attribute.value'])
+ gen = self.computer._get_possible_combinations()
+ with self.assertRaises(StopIteration):
+ next(gen)
- def test_get_closest_possible_combination(self):
+ def test_get_closest_possible_combinations(self):
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
+ computer_ssd_512 = self._get_product_template_attribute_value(self.ssd_512)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
computer_ram_16 = self._get_product_template_attribute_value(self.ram_16)
+ computer_ram_32 = self._get_product_template_attribute_value(self.ram_32)
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
computer_hdd_2 = self._get_product_template_attribute_value(self.hdd_2)
computer_hdd_4 = self._get_product_template_attribute_value(self.hdd_4)
self._add_exclude(computer_ram_16, computer_hdd_1)
- # CASE nothing special
- self.assertEqual(self.computer._get_closest_possible_combination(None),
- computer_ssd_256 + computer_ram_8 + computer_hdd_1)
+ # CASE nothing special (test 2 iterations)
+ gen = self.computer._get_closest_possible_combinations(None)
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_8 + computer_hdd_1)
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_8 + computer_hdd_2)
- # CASE contains computer_hdd_1
- self.assertEqual(self.computer._get_closest_possible_combination(computer_hdd_1),
- computer_ssd_256 + computer_ram_8 + computer_hdd_1)
+ # CASE contains computer_hdd_1 (test all iterations)
+ gen = self.computer._get_closest_possible_combinations(computer_hdd_1)
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_8 + computer_hdd_1)
+ self.assertEqual(next(gen), computer_ssd_256 + computer_ram_32 + computer_hdd_1)
+ self.assertEqual(next(gen), computer_ssd_512 + computer_ram_8 + computer_hdd_1)
+ self.assertEqual(next(gen), computer_ssd_512 + computer_ram_32 + computer_hdd_1)
+ with self.assertRaises(StopIteration):
+ next(gen)
# CASE contains computer_hdd_2
self.assertEqual(self.computer._get_closest_possible_combination(computer_hdd_2),
@@ -393,3 +422,27 @@ class TestProductAttributeValueConfig(TestProductAttributeValueSetup):
# CASE invalid combination (too much):
self.assertEqual(self.computer._get_closest_possible_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_4 + computer_hdd_2),
computer_ssd_256 + computer_ram_8 + computer_hdd_4)
+
+ def test_clear_caches(self):
+ """The goal of this test is to make sure the cache is invalidated when
+ it should be."""
+ attribute_values = self.ssd_256 + self.ram_8 + self.hdd_1
+
+ # CASE: initial result of _get_variant_id_for_combination
+ variant_id = self.computer._get_variant_id_for_combination(attribute_values)
+ self.assertTrue(variant_id)
+
+ # CASE: clear_caches in product.product unlink
+ self.env['product.product'].browse(variant_id).unlink()
+ self.assertFalse(self.computer._get_variant_id_for_combination(attribute_values))
+
+ # CASE: clear_caches in product.product create
+ variant = self.env['product.product'].create({
+ 'product_tmpl_id': self.computer.id,
+ 'attribute_value_ids': [(6, 0, attribute_values.ids)],
+ })
+ self.assertEqual(variant.id, self.computer._get_variant_id_for_combination(attribute_values))
+
+ # CASE: clear_caches in product.product write
+ variant.attribute_value_ids = False
+ self.assertFalse(self.computer._get_variant_id_for_combination(attribute_values))
diff --git a/addons/product/views/product_views.xml b/addons/product/views/product_views.xml
index faf0ce4e454..5aab1bd8977 100644
--- a/addons/product/views/product_views.xml
+++ b/addons/product/views/product_views.xml
@@ -52,7 +52,7 @@
<field name="list_price" string="Sales Price" widget='monetary' options="{'currency_field': 'currency_id', 'field_digits': True}"/>
<label for="standard_price" groups="base.group_user" attrs="{'invisible': [('product_variant_count', '&gt;', 1), ('is_product_variant', '=', False)]}"/>
<div name="standard_price_uom" groups="base.group_user" attrs="{'invisible': [('product_variant_count', '&gt;', 1), ('is_product_variant', '=', False)]}" class="o_row">
- <field name="standard_price" widget='monetary' options="{'currency_field': 'currency_id'}"/>
+ <field name="standard_price" widget='monetary' options="{'currency_field': 'cost_currency_id'}"/>
<span groups="uom.group_uom" class="oe_read_only">per
<field name="uom_name"/>
</span>
@@ -62,6 +62,7 @@
<field name="uom_id" groups="uom.group_uom" options="{'no_create': True}"/>
<field name="uom_po_id" groups="uom.group_uom" options="{'no_create': True}"/>
<field name="currency_id" invisible="1"/>
+ <field name="cost_currency_id" invisible="1"/>
<field name="product_variant_id" invisible="1"/>
</group>
</group>
diff --git a/addons/purchase_requisition/models/purchase_requisition.py b/addons/purchase_requisition/models/purchase_requisition.py
index 6c9871cb6e9..2cf546eab41 100644
--- a/addons/purchase_requisition/models/purchase_requisition.py
+++ b/addons/purchase_requisition/models/purchase_requisition.py
@@ -244,7 +244,7 @@ class PurchaseRequisitionLine(models.Model):
@api.onchange('product_id')
def _onchange_product_id(self):
if self.product_id:
- self.product_uom_id = self.product_id.uom_id
+ self.product_uom_id = self.product_id.uom_po_id
self.product_qty = 1.0
if not self.schedule_date:
self.schedule_date = self.requisition_id.schedule_date
diff --git a/addons/purchase_requisition/views/purchase_requisition_views.xml b/addons/purchase_requisition/views/purchase_requisition_views.xml
index 82496969d51..18b5a82dd3d 100644
--- a/addons/purchase_requisition/views/purchase_requisition_views.xml
+++ b/addons/purchase_requisition/views/purchase_requisition_views.xml
@@ -30,7 +30,7 @@
<div class="o_kanban_record_headings mt4">
<strong class="o_kanban_record_title"><field name="name"/></strong>
</div>
- <field name="exclusive" widget="kanban_label_selection"/>
+ <field name="exclusive" widget="label_selection"/>
</div>
</div>
</t>
@@ -225,7 +225,7 @@
<div class="o_kanban_record_headings mt4">
<strong class="o_kanban_record_title"><span><field name="name"/></span></strong>
</div>
- <field name="state" widget="kanban_label_selection" options="{'classes': {'draft': 'default', 'in_progress': 'default', 'open': 'success', 'done': 'success', 'close': 'danger'}}"/>
+ <field name="state" widget="label_selection" options="{'classes': {'draft': 'default', 'in_progress': 'default', 'open': 'success', 'done': 'success', 'close': 'danger'}}"/>
</div>
<div class="o_kanban_record_body">
<span class="text-muted"><field name="type_id"/></span>
diff --git a/addons/purchase_stock/models/purchase.py b/addons/purchase_stock/models/purchase.py
index a2d45c57c31..968bcffef67 100644
--- a/addons/purchase_stock/models/purchase.py
+++ b/addons/purchase_stock/models/purchase.py
@@ -370,12 +370,11 @@ class PurchaseOrderLine(models.Model):
@api.multi
def _create_stock_moves(self, picking):
- moves = self.env['stock.move']
- done = self.env['stock.move'].browse()
+ values = []
for line in self:
for val in line._prepare_stock_moves(picking):
- done += moves.create(val)
- return done
+ values.append(val)
+ return self.env['stock.move'].create(values)
def _find_candidate(self, product_id, product_qty, product_uom, location_id, name, origin, company_id, values):
""" Return the record in self where the procument with values passed as
diff --git a/addons/repair/views/repair_views.xml b/addons/repair/views/repair_views.xml
index 787b43e7146..30b6f7a369c 100644
--- a/addons/repair/views/repair_views.xml
+++ b/addons/repair/views/repair_views.xml
@@ -208,7 +208,7 @@
<strong><span><t t-esc="record.name.value"/></span></strong>
</div>
<div class="col-6 text-right">
- <span class="badge badge-secondary"><field name="state" widget="kanban_label_selection" options="{'classes': {'draft': 'info', 'cancel': 'danger', 'done': 'success', 'under_repair': 'dafault'}}"/></span>
+ <span class="badge badge-secondary"><field name="state" widget="label_selection" options="{'classes': {'draft': 'info', 'cancel': 'danger', 'done': 'success', 'under_repair': 'dafault'}}"/></span>
</div>
</div>
<div class="row">
diff --git a/addons/sale/controllers/product_configurator.py b/addons/sale/controllers/product_configurator.py
index 5a5ae1b26d9..eea3dfe67fd 100644
--- a/addons/sale/controllers/product_configurator.py
+++ b/addons/sale/controllers/product_configurator.py
@@ -41,7 +41,10 @@ class ProductConfiguratorController(http.Controller):
def get_combination_info(self, product_template_id, product_id, combination, add_qty, pricelist_id, **kw):
combination = request.env['product.template.attribute.value'].browse(combination)
pricelist = self._get_pricelist(pricelist_id)
- return request.env['product.template'].browse(int(product_template_id))._get_combination_info(combination, int(product_id or 0), int(add_qty or 1), pricelist)
+ ProductTemplate = request.env['product.template']
+ if 'context' in kw:
+ ProductTemplate = ProductTemplate.with_context(**kw.get('context'))
+ return ProductTemplate.browse(int(product_template_id))._get_combination_info(combination, int(product_id or 0), int(add_qty or 1), pricelist)
@http.route(['/product_configurator/create_product_variant'], type='json', auth="user", methods=['POST'])
def create_product_variant(self, product_template_id, product_template_attribute_value_ids, **kwargs):
diff --git a/addons/sale/data/mail_data.xml b/addons/sale/data/mail_data.xml
index 50c42763b67..70db8f8c5d2 100644
--- a/addons/sale/data/mail_data.xml
+++ b/addons/sale/data/mail_data.xml
@@ -57,7 +57,7 @@
</data>
<!-- Template and notification section -->
<data noupdate="1">
- <template id="" inherit_id="mail.mail_notification_paynow" name="oezirhgoi">
+ <template id="mail_notification_paynow_online" inherit_id="mail.mail_notification_paynow" name="oezirhgoi">
<xpath expr="//t[@t-set='access_name']" position="after">
<t t-if="record._name == 'sale.order'">
<t t-if="record.has_to_be_signed(include_draft=True)">
diff --git a/addons/sale/models/payment.py b/addons/sale/models/payment.py
index 167889bfbbd..04af968fa3e 100644
--- a/addons/sale/models/payment.py
+++ b/addons/sale/models/payment.py
@@ -112,7 +112,7 @@ class PaymentTransaction(models.Model):
ctx_company = {'company_id': self.acquirer_id.company_id.id,
'force_company': self.acquirer_id.company_id.id}
for trans in self.filtered(lambda t: t.sale_order_ids):
- trans = trans.with_context(ctx_company)
+ trans = trans.with_context(**ctx_company)
trans.sale_order_ids._force_lines_to_invoice_policy_order()
invoices = trans.sale_order_ids._create_invoices()
trans.invoice_ids = [(6, 0, invoices)]
diff --git a/addons/sale/models/product_template.py b/addons/sale/models/product_template.py
index 3867c8af56f..3ce86eab876 100644
--- a/addons/sale/models/product_template.py
+++ b/addons/sale/models/product_template.py
@@ -171,7 +171,7 @@ class ProductTemplate(models.Model):
return res
@api.multi
- def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, parent_combination=False):
+ def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, parent_combination=False, only_template=False):
""" Return info about a given combination.
Note: this method does not take into account whether the combination is
@@ -196,6 +196,9 @@ class ProductTemplate(models.Model):
given, it will try to find the first possible combination, taking
into account parent_combination (if set) for the exclusion rules.
+ :param only_template: boolean, if set to True, get the info for the
+ template only: ignore combination and don't try to find variant
+
:return: dict with product/combination info:
- product_id: the variant id matching the combination (if it exists)
@@ -217,6 +220,8 @@ class ProductTemplate(models.Model):
discount applied (price < list_price), else False
"""
self.ensure_one()
+ # get the name before the change of context to benefit from prefetch
+ display_name = self.name
quantity = self.env.context.get('quantity', add_qty)
context = dict(self.env.context, quantity=quantity, pricelist=pricelist.id if pricelist else False)
@@ -224,10 +229,12 @@ class ProductTemplate(models.Model):
combination = combination or product_template.env['product.template.attribute.value']
- if not product_id and not combination:
+ if not product_id and not combination and not only_template:
combination = product_template._get_first_possible_combination(parent_combination)
- if product_id and not combination:
+ if only_template:
+ product = product_template.env['product.product']
+ elif product_id and not combination:
product = product_template.env['product.product'].browse(product_id)
else:
product = product_template._get_variant_for_combination(combination)
@@ -255,8 +262,6 @@ class ProductTemplate(models.Model):
list_price = product_template.price_compute('list_price')[product_template.id]
price = product_template.price if pricelist else list_price
- display_name = product_template.name
-
filtered_combination = combination._without_no_variant_attributes()
if filtered_combination:
display_name = '%s (%s)' % (display_name, ', '.join(filtered_combination.mapped('name')))
@@ -283,8 +288,7 @@ class ProductTemplate(models.Model):
def _is_add_to_cart_possible(self, parent_combination=None):
"""
It's possible to add to cart (potentially after configuration) if
- there is at least one possible combination, or if there is no
- `product.template.attribute.line` at all.
+ there is at least one possible combination.
:param parent_combination: the combination from which `self` is an
optional or accessory product.
@@ -295,9 +299,9 @@ class ProductTemplate(models.Model):
"""
self.ensure_one()
if not self.active:
+ # for performance: avoid calling `_get_possible_combinations`
return False
- combination = self._get_first_possible_combination(parent_combination)
- return True if combination else self._is_combination_possible(combination, parent_combination)
+ return next(self._get_possible_combinations(parent_combination), False) is not False
@api.multi
def _get_current_company_fallback(self, **kwargs):
diff --git a/addons/sale/models/res_config_settings.py b/addons/sale/models/res_config_settings.py
index 27315c8d5a2..43cdf1d8c15 100644
--- a/addons/sale/models/res_config_settings.py
+++ b/addons/sale/models/res_config_settings.py
@@ -74,6 +74,8 @@ class ResConfigSettings(models.TransientModel):
def set_values(self):
super(ResConfigSettings, self).set_values()
+ if self.default_invoice_policy != 'order':
+ self.env['ir.config_parameter'].set_param('sale.automatic_invoice', False)
if not self.group_discount_per_so_line:
pl = self.env['product.pricelist'].search([('discount_policy', '=', 'without_discount')])
pl.write({'discount_policy': 'with_discount'})
diff --git a/addons/sale/models/res_partner.py b/addons/sale/models/res_partner.py
index d03c877b938..a1f60defb2d 100644
--- a/addons/sale/models/res_partner.py
+++ b/addons/sale/models/res_partner.py
@@ -14,14 +14,20 @@ class ResPartner(models.Model):
sale_warn_msg = fields.Text('Message for Sales Order')
def _compute_sale_order_count(self):
- sale_data = self.env['sale.order'].read_group(domain=[('partner_id', 'child_of', self.ids)],
- fields=['partner_id'], groupby=['partner_id'])
- mapped_data = dict([(m['partner_id'][0], m['partner_id_count']) for m in sale_data])
- for partner in self:
- # let's obtain the partner id and all its child ids
- partner_ids = [partner.id] + partner.child_ids.ids
- # then we can sum for all the partner's child
- partner.sale_order_count = sum(mapped_data.get(child, 0) for child in partner_ids)
+ # retrieve all children partners and prefetch 'parent_id' on them
+ all_partners = self.search([('id', 'child_of', self.ids)])
+ all_partners.read(['parent_id'])
+
+ sale_order_groups = self.env['sale.order'].read_group(
+ domain=[('partner_id', 'in', all_partners.ids)],
+ fields=['partner_id'], groupby=['partner_id']
+ )
+ for group in sale_order_groups:
+ partner = self.browse(group['partner_id'][0])
+ while partner:
+ if partner in self:
+ partner.sale_order_count += group['partner_id_count']
+ partner = partner.parent_id
def can_edit_vat(self):
can_edit_vat = super(ResPartner, self).can_edit_vat()
diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py
index cbcf2c79f23..22b8d24e131 100644
--- a/addons/sale/models/sale.py
+++ b/addons/sale/models/sale.py
@@ -304,7 +304,7 @@ class SaleOrder(models.Model):
'payment_term_id': self.partner_id.property_payment_term_id and self.partner_id.property_payment_term_id.id or False,
'partner_invoice_id': addr['invoice'],
'partner_shipping_id': addr['delivery'],
- 'user_id': self.partner_id.user_id.id or self.env.uid
+ 'user_id': self.partner_id.user_id.id or self.partner_id.commercial_partner_id.user_id.id or self.env.uid
}
if self.env['ir.config_parameter'].sudo().get_param('account.use_invoice_terms') and self.env.user.company_id.invoice_terms:
values['note'] = self.with_context(lang=self.partner_id.lang).env.user.company_id.invoice_terms
@@ -636,11 +636,15 @@ class SaleOrder(models.Model):
if email_act and email_act.get('context'):
email_ctx = email_act['context']
email_ctx.update(default_email_from=order.company_id.email)
- order.with_context(email_ctx).message_post_with_template(email_ctx.get('default_template_id'))
+ order.with_context(**email_ctx).message_post_with_template(email_ctx.get('default_template_id'))
return True
@api.multi
def action_done(self):
+ tx = self.sudo().transaction_ids.get_last_transaction()
+ if tx and tx.state == 'pending' and tx.acquirer_id.provider == 'transfer':
+ tx._set_transaction_done()
+ tx.write({'is_processed': True})
return self.write({'state': 'done'})
@api.multi
@@ -1428,6 +1432,16 @@ class SaleOrderLine(models.Model):
result = {'domain': domain}
+ name = self.get_sale_order_line_multiline_description_sale(product)
+
+ vals.update(name=name)
+
+ self._compute_tax_id()
+
+ if self.order_id.pricelist_id and self.order_id.partner_id:
+ vals['price_unit'] = self.env['account.tax']._fix_tax_included_price_company(self._get_display_price(product), product.taxes_id, self.tax_id, self.company_id)
+ self.update(vals)
+
title = False
message = False
warning = {}
@@ -1440,16 +1454,6 @@ class SaleOrderLine(models.Model):
if product.sale_line_warn == 'block':
self.product_id = False
- name = self.get_sale_order_line_multiline_description_sale(product)
-
- vals.update(name=name)
-
- self._compute_tax_id()
-
- if self.order_id.pricelist_id and self.order_id.partner_id:
- vals['price_unit'] = self.env['account.tax']._fix_tax_included_price_company(self._get_display_price(product), product.taxes_id, self.tax_id, self.company_id)
- self.update(vals)
-
return result
@api.onchange('product_uom', 'product_uom_qty')
diff --git a/addons/sale/report/sale_report_templates.xml b/addons/sale/report/sale_report_templates.xml
index 8e678b1fcd6..60757646486 100644
--- a/addons/sale/report/sale_report_templates.xml
+++ b/addons/sale/report/sale_report_templates.xml
@@ -192,9 +192,9 @@
<p t-if="doc.payment_term_id.note">
<span t-field="doc.payment_term_id.note"/>
</p>
- <p id="fiscal_position_remark" t-if="doc.fiscal_position_id and doc.fiscal_position_id.note">
+ <p id="fiscal_position_remark" t-if="doc.fiscal_position_id and doc.fiscal_position_id.sudo().note">
<strong>Fiscal Position Remark:</strong>
- <span t-field="doc.fiscal_position_id.note"/>
+ <span t-field="doc.fiscal_position_id.sudo().note"/>
</p>
<div t-if="doc.signature" class="mt32 ml16 mr16" name="signature">
diff --git a/addons/sale/static/src/js/product_configurator_mixin.js b/addons/sale/static/src/js/product_configurator_mixin.js
index 1bf7cca730a..25cce5c4260 100644
--- a/addons/sale/static/src/js/product_configurator_mixin.js
+++ b/addons/sale/static/src/js/product_configurator_mixin.js
@@ -183,7 +183,7 @@ var ProductConfiguratorMixin = {
*/
triggerVariantChange: function ($container) {
var self = this;
- $container.find('ul[data-attribute_exclusions]').trigger('change');
+ $container.find('ul[data-attribute_exclusions]').trigger('change', {$container: $container});
$container.find('input.js_variant_change:checked, select.js_variant_change').each(function () {
self.handleCustomValues($(this));
});
@@ -559,14 +559,16 @@ var ProductConfiguratorMixin = {
_updateProductImage: function ($productContainer, productId, productTemplateId) {
var model = productId ? 'product.product' : 'product.template';
var modelId = productId || productTemplateId;
- var imageSrc = '/web/image/{0}/{1}/image'
+ var imageUrl = '/web/image/{0}/{1}/' + (this._productImageField ? this._productImageField : 'image');
+ var imageSrc = imageUrl
.replace("{0}", model)
.replace("{1}", modelId);
var imagesSelectors = [
'span[data-oe-model^="product."][data-oe-type="image"] img:first',
'img.product_detail_img',
- 'span.variant_image img'
+ 'span.variant_image img',
+ 'img.variant_image',
];
var $img = $productContainer.find(imagesSelectors.join(', '));
diff --git a/addons/sale/static/src/js/product_configurator_modal.js b/addons/sale/static/src/js/product_configurator_modal.js
index e8ad60b66d6..3bcebf88725 100644
--- a/addons/sale/static/src/js/product_configurator_modal.js
+++ b/addons/sale/static/src/js/product_configurator_modal.js
@@ -69,9 +69,9 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
this.dialogClass = 'oe_optional_products_modal' + (params.isWebsite ? ' oe_website_sale' : '');
if (this.isWebsite) {
- delete this.events['change [data-attribute_exclusions]'];
delete this.events['click button.js_add_cart_json'];
}
+ this._productImageField = 'image_medium';
},
/**
* @override
@@ -438,7 +438,17 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
delete optionalProductsMap[optionId];
}
},
+ /**
+ * @override
+ */
+ _onChangeCombination:function (ev, $parent, combination) {
+ $parent
+ .find('.td-product_name .product-name')
+ .first()
+ .text(combination.display_name);
+ ProductConfiguratorMixin._onChangeCombination.apply(this, arguments);
+ },
/**
* When the quantity of the root product is updated, we need to update
* the quantity of all the selected optional products.
diff --git a/addons/sale/tests/test_sale_product_attribute_value_config.py b/addons/sale/tests/test_sale_product_attribute_value_config.py
index 95b1814f50e..087cbe78de5 100644
--- a/addons/sale/tests/test_sale_product_attribute_value_config.py
+++ b/addons/sale/tests/test_sale_product_attribute_value_config.py
@@ -80,8 +80,13 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup):
def test_01_is_combination_possible_archived(self):
"""The goal is to test the possibility of archived combinations.
+
This test could not be put into product module because there was no
- field which had product_id as required and without cascade on delete.
+ model which had product_id as required and without cascade on delete.
+ Here we have the sales order line in this situation.
+
+ This is a necessary condition for `create_variant_ids` to archive
+ instead of delete the variants.
"""
def do_test(self):
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
@@ -110,6 +115,7 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup):
variant2.active = False
# CASE: 1 not archived, 2 archived
+ # Test invalidate_cache on product.product write
self.assertTrue(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1))
self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_2))
# CASE: both archived combination (without no_variant)
@@ -261,6 +267,7 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup):
self.assertEqual(res['list_price'], 2222 * currency_ratio)
# CASE: dynamic combination, no variant existing
+ # Test invalidate_cache on product.template create_variant_ids
self._add_keyboard_attribute()
self.computer.create_variant_ids()
combination += self._get_product_template_attribute_value(self.keyboard_excluded)
@@ -272,8 +279,8 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup):
self.assertEqual(res['list_price'], (2222 - 5) * currency_ratio)
# CASE: pricelist set value to 0, no variant
+ # Test invalidate_cache on product.pricelist write
pricelist_item.percent_price = 100
- self.computer.invalidate_cache() # need o2m to be refetched
res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist)
self.assertEqual(res['product_template_id'], self.computer.id)
self.assertEqual(res['product_id'], False)
@@ -346,7 +353,6 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup):
self.hdd_attribute.create_variant = 'dynamic'
self._add_hdd_attribute_line()
self.computer.create_variant_ids()
- self.computer.invalidate_cache()
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
diff --git a/addons/sale_management/models/sale_order.py b/addons/sale_management/models/sale_order.py
index afa13aed50d..6ebed05a2bc 100644
--- a/addons/sale_management/models/sale_order.py
+++ b/addons/sale_management/models/sale_order.py
@@ -209,4 +209,5 @@ class SaleOrderOption(models.Model):
'product_uom_qty': self.quantity,
'product_uom': self.uom_id.id,
'discount': self.discount,
+ 'company_id': self.order_id.company_id.id,
}
diff --git a/addons/sale_management/models/sale_order_template.py b/addons/sale_management/models/sale_order_template.py
index 45bcf2bb752..8eef6efd405 100644
--- a/addons/sale_management/models/sale_order_template.py
+++ b/addons/sale_management/models/sale_order_template.py
@@ -120,7 +120,10 @@ class SaleOrderTemplateOption(models.Model):
return
product = self.product_id
self.price_unit = product.list_price
- self.name = product.name
+ name = product.name
+ if self.product_id.description_sale:
+ name += '\n' + self.product_id.description_sale
+ self.name = name
self.uom_id = product.uom_id
domain = {'uom_id': [('category_id', '=', self.product_id.uom_id.category_id.id)]}
return {'domain': domain}
diff --git a/addons/sale_management/static/tests/product_configurator_advanced_test_ui.js b/addons/sale_management/static/tests/product_configurator_advanced_test_ui.js
index 1cccd7fe0e1..074da95f06e 100644
--- a/addons/sale_management/static/tests/product_configurator_advanced_test_ui.js
+++ b/addons/sale_management/static/tests/product_configurator_advanced_test_ui.js
@@ -3,6 +3,8 @@ odoo.define('sale.sale_product_configurator_advanced_tour', function (require) {
var tour = require('web_tour.tour');
+var optionVariantImage;
+
tour.register('sale_product_configurator_advanced_tour', {
url: "/web",
test: true,
@@ -96,6 +98,25 @@ tour.register('sale_product_configurator_advanced_tour', {
extra_trigger: '.oe_optional_products_modal',
run: function () {} //check
}, {
+ trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("Conference Chair (Steel)")',
+ run: function () {
+ optionVariantImage = $('.oe_optional_products_modal .js_product:eq(1) img.variant_image').attr('src');
+ }
+}, {
+ trigger: '.oe_optional_products_modal .js_product:eq(1) input[data-value_name="Aluminium"]',
+}, {
+ trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("Conference Chair (Aluminium)")',
+ run: function () {
+ var newVariantImage = $('.oe_optional_products_modal .js_product:eq(1) img.variant_image').attr('src');
+ if (newVariantImage !== optionVariantImage) {
+ $('<p>').text('image variant option src changed').insertAfter('.oe_optional_products_modal .js_product:eq(1) .product-name');
+ }
+
+ }
+}, {
+ extra_trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("image variant option src changed")',
+ trigger: '.oe_optional_products_modal .js_product:eq(1) input[data-value_name="Steel"]',
+}, {
trigger: 'button span:contains(Confirm)',
extra_trigger: '.oe_optional_products_modal',
run: 'click'
diff --git a/addons/sale_management/views/sale_order_views.xml b/addons/sale_management/views/sale_order_views.xml
index 2d97bc75956..f18f8a429c4 100644
--- a/addons/sale_management/views/sale_order_views.xml
+++ b/addons/sale_management/views/sale_order_views.xml
@@ -8,7 +8,58 @@
<xpath expr="//page/field[@name='order_line']/.." position="after">
<page string="Optional Products" name="optional_products">
- <field name="sale_order_option_ids">
+ <field name="sale_order_option_ids" mode="tree,form,kanban">
+ <form string="Optional Products">
+ <group>
+ <field name="product_id"/>
+ <field name="name"/>
+ <field name="quantity"/>
+ <field name="uom_id" groups="uom.group_uom"/>
+ <field name="price_unit"/>
+ <field name="discount" groups="sale.group_discount_per_so_line"/>
+ </group>
+ </form>
+ <kanban class="o_kanban_mobile">
+ <field name="product_id"/>
+ <field name="quantity"/>
+ <field name="uom_id" groups="uom.group_uom"/>
+ <field name="price_unit"/>
+ <templates>
+ <t t-name="kanban-box">
+ <div class="oe_kanban_card oe_kanban_global_click">
+ <div class="row">
+ <div class="col-10">
+ <strong>
+ <span>
+ <t t-esc="record.product_id.value"/>
+ </span>
+ </strong>
+ </div>
+ <div class="col-2">
+ <button name="button_add_to_order" class="btn btn-link oe_link fa fa-shopping-cart" string="Add to order lines" type="object"/>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-12 text-muted">
+ <span>
+ Quantity:
+ <t t-esc="record.quantity.value"/>
+ <t t-esc="record.uom_id.value"/>
+ </span>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-12 text-muted">
+ <span>
+ Unit Price:
+ <t t-esc="record.price_unit.value"/>
+ </span>
+ </div>
+ </div>
+ </div>
+ </t>
+ </templates>
+ </kanban>
<tree string="Sales Quotation Template Lines" editable="bottom">
<control>
<create name="add_product_control" string="Add a product"/>
diff --git a/addons/stock/models/stock_inventory.py b/addons/stock/models/stock_inventory.py
index 263476c140e..d3111d2564f 100644
--- a/addons/stock/models/stock_inventory.py
+++ b/addons/stock/models/stock_inventory.py
@@ -267,7 +267,7 @@ class Inventory(models.Model):
args += (self.package_id.id,)
#case 5: Filter on One product category + Exahausted Products
if self.category_id:
- categ_products = Product.search([('categ_id', '=', self.category_id.id)])
+ categ_products = Product.search([('categ_id', 'child_of', self.category_id.id)])
domain += ' AND product_id = ANY (%s)'
args += (categ_products.ids,)
products_to_filter |= categ_products
diff --git a/addons/stock/models/stock_location.py b/addons/stock/models/stock_location.py
index 5a70ccd0c76..f5b3edb17d1 100644
--- a/addons/stock/models/stock_location.py
+++ b/addons/stock/models/stock_location.py
@@ -193,9 +193,9 @@ class Route(models.Model):
'res.company', 'Company',
default=lambda self: self.env['res.company']._company_default_get('stock.location.route'), index=True,
help='Leave this field empty if this route is shared between all companies')
- product_ids = fields.Many2many('product.template', 'stock_route_product', 'route_id', 'product_id', 'Products')
- categ_ids = fields.Many2many('product.category', 'stock_location_route_categ', 'route_id', 'categ_id', 'Product Categories')
- warehouse_ids = fields.Many2many('stock.warehouse', 'stock_route_warehouse', 'route_id', 'warehouse_id', 'Warehouses')
+ product_ids = fields.Many2many('product.template', 'stock_route_product', 'route_id', 'product_id', 'Products', copy=False)
+ categ_ids = fields.Many2many('product.category', 'stock_location_route_categ', 'route_id', 'categ_id', 'Product Categories', copy=False)
+ warehouse_ids = fields.Many2many('stock.warehouse', 'stock_route_warehouse', 'route_id', 'warehouse_id', 'Warehouses', copy=False)
@api.onchange('warehouse_selectable')
def _onchange_warehouse_selectable(self):
diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py
index bcea5af01f1..e8e6ecce0e4 100644
--- a/addons/stock/models/stock_move.py
+++ b/addons/stock/models/stock_move.py
@@ -1152,7 +1152,8 @@ class StockMove(models.Model):
moves_todo.mapped('move_dest_ids')._action_assign()
# We don't want to create back order for scrap moves
- if all(move_todo.scrapped for move_todo in moves_todo):
+ # Replace by a kwarg in master
+ if self.env.context.get('is_scrap'):
return moves_todo
if picking and not cancel_backorder:
diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py
index 6827f1bbd8c..bc60cb19265 100644
--- a/addons/stock/models/stock_picking.py
+++ b/addons/stock/models/stock_picking.py
@@ -723,7 +723,7 @@ class Picking(models.Model):
# If no lots when needed, raise error
picking_type = self.picking_type_id
precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure')
- no_quantities_done = all(float_is_zero(move_line.qty_done, precision_digits=precision_digits) for move_line in self.move_line_ids)
+ no_quantities_done = all(float_is_zero(move_line.qty_done, precision_digits=precision_digits) for move_line in self.move_line_ids.filtered(lambda m: m.state not in ('done', 'cancel')))
no_reserved_quantities = all(float_is_zero(move_line.product_qty, precision_rounding=move_line.product_uom_id.rounding) for move_line in self.move_line_ids)
if no_reserved_quantities and no_quantities_done:
raise UserError(_('You cannot validate a transfer if no quantites are reserved nor done. To force the transfer, switch in edit more and encode the done quantities.'))
diff --git a/addons/stock/models/stock_scrap.py b/addons/stock/models/stock_scrap.py
index bf7026b5366..fc7e9e65c94 100644
--- a/addons/stock/models/stock_scrap.py
+++ b/addons/stock/models/stock_scrap.py
@@ -114,7 +114,8 @@ class StockScrap(models.Model):
def do_scrap(self):
for scrap in self:
move = self.env['stock.move'].create(scrap._prepare_move_values())
- move._action_done()
+ # master: replace context by cancel_backorder
+ move.with_context(is_scrap=True)._action_done()
scrap.write({'move_id': move.id, 'state': 'done'})
return True
diff --git a/addons/stock/models/stock_warehouse.py b/addons/stock/models/stock_warehouse.py
index ce2e3bfa374..4c5eff15868 100644
--- a/addons/stock/models/stock_warehouse.py
+++ b/addons/stock/models/stock_warehouse.py
@@ -496,12 +496,39 @@ class Warehouse(models.Model):
def_values = self.default_get(['reception_steps', 'delivery_steps'])
reception_steps = vals.get('reception_steps', def_values['reception_steps'])
delivery_steps = vals.get('delivery_steps', def_values['delivery_steps'])
+ code = vals.get('code') or self.code
+ code = code.replace(' ', '').upper()
sub_locations = {
- 'lot_stock_id': {'name': _('Stock'), 'active': True, 'usage': 'internal'},
- 'wh_input_stock_loc_id': {'name': _('Input'), 'active': reception_steps != 'one_step', 'usage': 'internal'},
- 'wh_qc_stock_loc_id': {'name': _('Quality Control'), 'active': reception_steps == 'three_steps', 'usage': 'internal'},
- 'wh_output_stock_loc_id': {'name': _('Output'), 'active': delivery_steps != 'ship_only', 'usage': 'internal'},
- 'wh_pack_stock_loc_id': {'name': _('Packing Zone'), 'active': delivery_steps == 'pick_pack_ship', 'usage': 'internal'},
+ 'lot_stock_id': {
+ 'name': _('Stock'),
+ 'active': True,
+ 'usage': 'internal',
+ 'barcode': code + '-STOCK'
+ },
+ 'wh_input_stock_loc_id': {
+ 'name': _('Input'),
+ 'active': reception_steps != 'one_step',
+ 'usage': 'internal',
+ 'barcode': code + '-INPUT'
+ },
+ 'wh_qc_stock_loc_id': {
+ 'name': _('Quality Control'),
+ 'active': reception_steps == 'three_steps',
+ 'usage': 'internal',
+ 'barcode': code + '-QUALITY'
+ },
+ 'wh_output_stock_loc_id': {
+ 'name': _('Output'),
+ 'active': delivery_steps != 'ship_only',
+ 'usage': 'internal',
+ 'barcode': code + '-OUTPUT'
+ },
+ 'wh_pack_stock_loc_id': {
+ 'name': _('Packing Zone'),
+ 'active': delivery_steps == 'pick_pack_ship',
+ 'usage': 'internal',
+ 'barcode': code + '-PACKING'
+ },
}
return sub_locations
diff --git a/addons/stock/tests/test_move.py b/addons/stock/tests/test_move.py
index 5060d7343fc..e009fc5ee7d 100644
--- a/addons/stock/tests/test_move.py
+++ b/addons/stock/tests/test_move.py
@@ -3479,6 +3479,15 @@ class StockMove(TransactionCase):
})
picking.action_confirm()
picking.action_assign()
+
+ scrap = self.env['stock.scrap'].create({
+ 'picking_id': picking.id,
+ 'product_id': self.product1.id,
+ 'product_uom_id': self.uom_unit.id,
+ 'scrap_qty': 5.0,
+ })
+ scrap.do_scrap()
+
# No products are reserved on the move of 10, click on `button_validate`.
with self.assertRaises(UserError):
picking.button_validate()
diff --git a/addons/stock/tests/test_move2.py b/addons/stock/tests/test_move2.py
index 13b092a9407..dd75243ffa3 100644
--- a/addons/stock/tests/test_move2.py
+++ b/addons/stock/tests/test_move2.py
@@ -1811,6 +1811,9 @@ class TestSinglePicking(TestStockCommon):
class TestStockUOM(TestStockCommon):
def setUp(self):
super(TestStockUOM, self).setUp()
+ self.setUpCache()
+
+ def setUpCache(self):
dp = self.env.ref('product.decimal_product_uom')
dp.digits = 7
@@ -1845,6 +1848,10 @@ class TestStockUOM(TestStockCommon):
'uom_po_id': T_LBS.id,
'tracking': 'lot',
})
+
+ # creating a variant invalidates the cache
+ self.setUpCache()
+
picking_in = self.env['stock.picking'].create({
'partner_id': self.partner_delta_id,
'picking_type_id': self.picking_type_in,
diff --git a/addons/stock/views/stock_inventory_views.xml b/addons/stock/views/stock_inventory_views.xml
index 0aef58e898c..eb1fd43c0d5 100644
--- a/addons/stock/views/stock_inventory_views.xml
+++ b/addons/stock/views/stock_inventory_views.xml
@@ -97,7 +97,7 @@
<div class="o_kanban_record_headings">
<strong class="o_kanban_record_title"><span><t t-esc="record.name.value"/></span></strong>
</div>
- <field name="state" widget="kanban_label_selection" options="{'classes': {'draft': 'info', 'cancel': 'default', 'confirm': 'success', 'done': 'success'}}"/>
+ <field name="state" widget="label_selection" options="{'classes': {'draft': 'info', 'cancel': 'default', 'confirm': 'success', 'done': 'success'}}"/>
</div>
<div class="o_kanban_record_bottom">
<div class="oe_kanban_bottom_left">
diff --git a/addons/stock/views/stock_move_views.xml b/addons/stock/views/stock_move_views.xml
index d9811745801..a89b280ddbc 100644
--- a/addons/stock/views/stock_move_views.xml
+++ b/addons/stock/views/stock_move_views.xml
@@ -447,7 +447,7 @@
</div>
<div class="col-4">
<span class="float-right text-right">
- <field name="state" widget="kanban_label_selection" options="{'classes': {'draft': 'default', 'cancel': 'danger',
+ <field name="state" widget="label_selection" options="{'classes': {'draft': 'default', 'cancel': 'danger',
'assigned': 'success', 'confirmed': 'default'}}"/>
</span>
</div>
@@ -511,7 +511,7 @@
</div>
<div class="col-6">
<span class="float-right">
- <field name="state" widget="kanban_label_selection" options="{'classes': {'draft': 'info', 'cancel': 'default',
+ <field name="state" widget="label_selection" options="{'classes': {'draft': 'info', 'cancel': 'default',
'assigned': 'success', 'done': 'success'}}"/>
</span>
</div>
diff --git a/addons/stock_account/views/product_views.xml b/addons/stock_account/views/product_views.xml
index 6e09f8416a3..d4920effef1 100644
--- a/addons/stock_account/views/product_views.xml
+++ b/addons/stock_account/views/product_views.xml
@@ -39,7 +39,7 @@
<field name="standard_price" position="replace">
<field name="standard_price"
attrs="{'readonly':[('valuation','=','real_time')]}"
- widget='monetary' options="{'currency_field': 'currency_id', 'field_digits': True}"
+ widget='monetary' options="{'currency_field': 'cost_currency_id', 'field_digits': True}"
class="float-left oe_inline"/>
<div name="update_cost_price">
<button string="Update Cost" type="action"
diff --git a/addons/stock_account/views/stock_account_views.xml b/addons/stock_account/views/stock_account_views.xml
index 45abf2db497..2bc4f61b89c 100644
--- a/addons/stock_account/views/stock_account_views.xml
+++ b/addons/stock_account/views/stock_account_views.xml
@@ -77,8 +77,8 @@
<field name="qty_at_date"/>
<field name="uom_id" groups="uom.group_uom"/>
<field name="currency_id" invisible="1"/>
- <field name="stock_value_currency_id" invisible="1"/>
- <field name="stock_value" sum="Stock Valuation" widget="monetary" options="{'currency_field': 'stock_value_currency_id'}"/>
+ <field name="cost_currency_id" invisible="1"/>
+ <field name="stock_value" sum="Stock Valuation" widget="monetary" options="{'currency_field': 'cost_currency_id'}"/>
<field name="cost_method" invisible="1"/>
<button name="action_valuation_at_date_details" type="object" icon="fa-info-circle" aria-label="Valuation" title="Valuation" attrs="{'invisible': [('cost_method', '!=', 'fifo')]}" />
</tree>
@@ -91,6 +91,10 @@
<field name="priority" eval="17"/>
<field name="arch" type="xml">
<search string="Inventory Valuation">
+ <field name="name" string="Product"/>
+ <field name="categ_id" />
+ <field name="product_tmpl_id" />
+ <field name="attribute_value_ids" />
<filter string="Category" name="categ_id" context="{'group_by':'categ_id'}"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
diff --git a/addons/stock_picking_batch/views/stock_picking_batch_views.xml b/addons/stock_picking_batch/views/stock_picking_batch_views.xml
index 8df5a5bccc6..aeec7d199ac 100644
--- a/addons/stock_picking_batch/views/stock_picking_batch_views.xml
+++ b/addons/stock_picking_batch/views/stock_picking_batch_views.xml
@@ -70,7 +70,7 @@
<div class="o_kanban_record_headings">
<strong class="o_kanban_record_title"><field name="name"/></strong>
</div>
- <field name="state" widget="kanban_label_selection"/>
+ <field name="state" widget="label_selection"/>
</div>
<div class="o_kanban_record_bottom">
<div class="oe_kanban_bottom_left"/>
diff --git a/addons/survey/data/mail_template_data.xml b/addons/survey/data/mail_template_data.xml
index de05a6a22d9..222d1fbae11 100644
--- a/addons/survey/data/mail_template_data.xml
+++ b/addons/survey/data/mail_template_data.xml
@@ -9,11 +9,19 @@
<div style="margin: 0px; padding: 0px; font-size: 13px;">
<p style="margin: 0px; padding: 0px; font-size: 13px;">
Dear ${object.partner_id.name or 'participant'}<br/><br/>
- We are conducting a survey and your response would be appreciated.
+ % if object.survey_id.certificate:
+ You have been invited to take a new certification.
+ % else:
+ We are conducting a survey and your response would be appreciated.
+ % endif
<div style="margin: 16px 0px 16px 0px;">
<a href="${('%s?answer_token=%s' % (object.survey_id.public_url, object.token)) | safe}"
style="background-color: #875A7B; padding: 8px 16px 8px 16px; text-decoration: none; color: #fff; border-radius: 5px; font-size:13px;">
- Start Survey
+ % if object.survey_id.certificate:
+ Start Certification
+ % else:
+ Start Survey
+ % endif
</a>
</div>
% if object.deadline:
diff --git a/addons/survey/models/survey_survey.py b/addons/survey/models/survey_survey.py
index e486ef9a41c..deb6978d0f7 100644
--- a/addons/survey/models/survey_survey.py
+++ b/addons/survey/models/survey_survey.py
@@ -70,6 +70,7 @@ class Survey(models.Model):
invite_count = fields.Integer("Invite", compute="_compute_survey_statistic")
answer_count = fields.Integer("Started", compute="_compute_survey_statistic")
answer_done_count = fields.Integer("Completed", compute="_compute_survey_statistic")
+ certified_count = fields.Integer("Certified", compute="_compute_certified_count")
# scoring and certification fields
scoring_type = fields.Selection([
@@ -104,7 +105,7 @@ class Survey(models.Model):
@api.depends('user_input_ids.state', 'user_input_ids.test_entry')
def _compute_survey_statistic(self):
- stat = dict.fromkeys(self.ids, {'invite_count': 0, 'answer_count': 0, 'answer_done_count': 0})
+ stat = dict((cid, dict(invite_count=0, answer_count=0, answer_done_count=0)) for cid in self.ids)
UserInput = self.env['survey.user_input']
base_domain = ['&', ('survey_id', 'in', self.ids), ('test_entry', '!=', True)]
@@ -119,6 +120,22 @@ class Survey(models.Model):
for survey in self:
survey.update(stat[survey.id])
+ @api.depends('certificate', 'user_input_ids.quizz_passed', 'user_input_ids.test_entry')
+ def _compute_certified_count(self):
+ stat = dict((cid, 0) for cid in self.ids)
+ certificate_surveys = self.filtered(lambda survey: survey.certificate)
+ if certificate_surveys:
+ read_group_res = self.env['survey.user_input'].read_group(
+ [('survey_id', 'in', certificate_surveys.ids), ('test_entry', '!=', True), ('quizz_passed', '=', True)],
+ [],
+ ['survey_id']
+ )
+ for item in read_group_res:
+ stat[item['survey_id'][0]] += item['survey_id_count']
+
+ for survey in self:
+ survey.certified_count = stat.get(survey.id, 0)
+
def _compute_survey_url(self):
""" Computes a public URL for the survey """
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
@@ -434,7 +451,7 @@ class Survey(models.Model):
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]:
+ if (not self.page_ids and self.questions_layout != 'page_per_question') or not self.question_ids:
raise UserError(_('You cannot send an invitation for a survey that has no questions.'))
if self.stage_id.closed:
@@ -504,6 +521,16 @@ class Survey(models.Model):
return action
@api.multi
+ def action_survey_user_input_certified(self):
+ action_rec = self.env.ref('survey.action_survey_user_input_notest')
+ action = action_rec.read()[0]
+ ctx = dict(self.env.context)
+ ctx.update({'search_default_survey_id': self.ids[0],
+ 'search_default_quizz_passed': 1})
+ action['context'] = ctx
+ return action
+
+ @api.multi
def action_survey_user_input_invite(self):
action_rec = self.env.ref('survey.action_survey_user_input_notest')
action = action_rec.read()[0]
diff --git a/addons/survey/models/survey_user.py b/addons/survey/models/survey_user.py
index 86d810ca588..e49d1314731 100644
--- a/addons/survey/models/survey_user.py
+++ b/addons/survey/models/survey_user.py
@@ -32,6 +32,7 @@ class SurveyUserInput(models.Model):
# description
survey_id = fields.Many2one('survey.survey', string='Survey', required=True, readonly=True, ondelete='cascade')
+ scoring_type = fields.Selection(string="Scoring", related="survey_id.scoring_type")
start_datetime = fields.Datetime('Start date and time', readonly=True)
is_time_limit_reached = fields.Boolean("Is time limit reached?", compute='_compute_is_time_limit_reached')
input_type = fields.Selection([
@@ -58,7 +59,7 @@ class SurveyUserInput(models.Model):
question_ids = fields.Many2many('survey.question', string='Predefined Questions', readonly=True)
deadline = fields.Datetime('Deadline', help="Datetime until customer can open the survey and submit answers")
- quizz_score = fields.Float("Score for the quiz (%)", compute="_compute_quizz_score", default=0.0)
+ quizz_score = fields.Float("Score (%)", compute="_compute_quizz_score")
# Stored for performance reasons while displaying results page
quizz_passed = fields.Boolean('Quizz Passed', compute='_compute_quizz_passed', store=True, compute_sudo=True)
diff --git a/addons/survey/static/src/js/tour_test_certification_failure.js b/addons/survey/static/src/js/tour_test_certification_failure.js
index 395e6b23990..baebb64864d 100644
--- a/addons/survey/static/src/js/tour_test_certification_failure.js
+++ b/addons/survey/static/src/js/tour_test_certification_failure.js
@@ -9,8 +9,8 @@ odoo.define('survey.tour_test_certification_failure', function (require) {
var tour = require('web_tour.tour');
var failSteps = [{ // Page-1
- content: "Clicking on Start Survey",
- trigger: 'a.btn.btn-primary.btn-lg:contains("Start Survey")',
+ content: "Clicking on Start Certification",
+ trigger: 'a.btn.btn-primary.btn-lg:contains("Start Certification")',
}, { // Question: Do we sell Acoustic Bloc Screens?
content: "Selecting answer 'No'",
trigger: 'div.js_question-wrapper:contains("Do we sell Acoustic Bloc Screens") select',
diff --git a/addons/survey/static/src/js/tour_test_certification_success.js b/addons/survey/static/src/js/tour_test_certification_success.js
index 9d0d7d4ec33..39108e1b7a4 100644
--- a/addons/survey/static/src/js/tour_test_certification_success.js
+++ b/addons/survey/static/src/js/tour_test_certification_success.js
@@ -8,8 +8,8 @@ tour.register('test_certification_success', {
url: '/survey/start/4ead4bc8-b8f2-4760-a682-1fde8ddb95ac'
},
[{ // Page-1
- content: "Clicking on Start Survey",
- trigger: 'a.btn.btn-primary.btn-lg:contains("Start Survey")',
+ content: "Clicking on Start Certification",
+ trigger: 'a.btn.btn-primary.btn-lg:contains("Start Certification")',
}, { // Question: Do we sell Acoustic Bloc Screens?
content: "Selecting answer 'Yes'",
trigger: 'div.js_question-wrapper:contains("Do we sell Acoustic Bloc Screens") select',
diff --git a/addons/survey/views/survey_survey_views.xml b/addons/survey/views/survey_survey_views.xml
index 5b44db5d660..43a7a44c26c 100644
--- a/addons/survey/views/survey_survey_views.xml
+++ b/addons/survey/views/survey_survey_views.xml
@@ -24,6 +24,13 @@
icon="fa-envelope-o">
<field string="Invitations" name="invite_count" widget="statinfo"/>
</button>
+ <button name="action_survey_user_input_certified"
+ type="object"
+ class="oe_stat_button"
+ attrs="{'invisible': [('certificate', '=', False)]}"
+ icon="fa-trophy">
+ <field string="Certified" name="certified_count" widget="statinfo"/>
+ </button>
<button name="action_survey_user_input_completed"
type="object"
class="oe_stat_button"
@@ -41,9 +48,9 @@
<group>
<group>
<field name="access_mode"/>
- <field name="public_url" readonly="1"
+ <field name="public_url" readonly="1"
attrs="{'invisible': [('access_mode', '!=', 'public')]}"
- widget="CopyClipboardText"/>
+ widget="CopyClipboardChar"/>
<field name="category" invisible="1"/>
</group>
</group>
diff --git a/addons/survey/views/survey_templates.xml b/addons/survey/views/survey_templates.xml
index e5f13cf766f..cbef0daac68 100644
--- a/addons/survey/views/survey_templates.xml
+++ b/addons/survey/views/survey_templates.xml
@@ -178,7 +178,12 @@
</p>
</div>
<a role="button" class="btn btn-primary btn-lg" t-att-href="'/survey/fill/%s/%s' % (survey.access_token, answer.token)">
- Start Survey
+ <t t-if="survey.certificate">
+ Start Certification
+ </t>
+ <t t-else="">
+ Start Survey
+ </t>
</a>
</div>
</div>
diff --git a/addons/survey/views/survey_user_views.xml b/addons/survey/views/survey_user_views.xml
index f7a58249125..6970442d8b9 100644
--- a/addons/survey/views/survey_user_views.xml
+++ b/addons/survey/views/survey_user_views.xml
@@ -16,6 +16,7 @@
<separator/>
<filter string="Invite" name="invite" domain="[('input_type', '=', 'link')]"/>
<separator/>
+ <filter string="Quizz passed" name="quizz_passed" domain="[('quizz_passed','=', True)]"/>
<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'}"/>
@@ -50,7 +51,9 @@
<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"/>
+ <field name="scoring_type" invisible="1"/>
+ <field name="quizz_score" attrs="{'invisible': [('scoring_type', '=', 'no_scoring')]}"/>
+ <field name="quizz_passed" attrs="{'invisible': [('scoring_type', '=', 'no_scoring')]}"/>
</group>
</group>
<field name="user_input_line_ids" mode="tree" attrs="{'readonly': False}">
@@ -76,10 +79,11 @@
<field name="deadline"/>
<field name="partner_id"/>
<field name="email"/>
- <field name="input_type"/>
+ <field name="input_type" groups="base.group_no_one"/>
<field name="state"/>
<field name="test_entry" invisible="True"/>
- <field name="quizz_score" groups="base.group_no_one"/>
+ <field name="quizz_passed"/>
+ <field name="quizz_score"/>
</tree>
</field>
</record>
@@ -109,7 +113,7 @@
<field name="create_date"/>
</div>
<div class="oe_kanban_bottom_right mr4">
- <field name="state" widget="kanban_label_selection" options="{'classes': {'new': 'default', 'done': 'success', 'skip':'warning'}}"/>
+ <field name="state" widget="label_selection" options="{'classes': {'new': 'default', 'done': 'success', 'skip':'warning'}}"/>
</div>
</div>
</div>
@@ -220,4 +224,4 @@
<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
+</odoo>
diff --git a/addons/survey/wizard/survey_invite_views.xml b/addons/survey/wizard/survey_invite_views.xml
index 03603d995a0..d891fff0fbf 100644
--- a/addons/survey/wizard/survey_invite_views.xml
+++ b/addons/survey/wizard/survey_invite_views.xml
@@ -12,7 +12,7 @@
<field name="survey_users_login_required" invisible="1"/>
<field name="survey_id" readonly="context.get('default_survey_id')"/>
<field name="existing_mode" widget="radio" invisible="1" />
- <field name="survey_url" label="Public share URL" readonly="1" widget="CopyClipboardText"
+ <field name="survey_url" label="Public share URL" readonly="1" widget="CopyClipboardChar"
attrs="{'invisible':[('survey_access_mode', '!=', 'public')]}"
class="mb16"/>
<field name="partner_ids"
diff --git a/addons/test_mail/tests/test_message_track.py b/addons/test_mail/tests/test_message_track.py
index 5906e7c0543..7055fe69bc9 100644
--- a/addons/test_mail/tests/test_message_track.py
+++ b/addons/test_mail/tests/test_message_track.py
@@ -149,3 +149,9 @@ class TestTracking(common.BaseFunctionalTest, common.MockEmails):
msg_admin = self.partner_admin.sudo(self.user_admin)._notify_prepare_template_context(self.record.message_ids, self.record)
self.assertFalse(msg_emp.get('tracking_values'), "should not have protected tracking values")
self.assertTrue(msg_admin.get('tracking_values'), "should have protected tracking values")
+
+ def test_unknown_field(self):
+ self.record.sudo().write({'email_from': 'X'}) # create a tracking value
+ tracking = self.record.sudo().mapped('message_ids.tracking_value_ids')[0]
+ tracking.field = 'I do not exist'
+ self.assertEqual(tracking.groups, 'base.group_system')
diff --git a/addons/test_mass_mailing/tests/test_composer.py b/addons/test_mass_mailing/tests/test_composer.py
index 26a16d9dd77..096b2f3d98a 100644
--- a/addons/test_mass_mailing/tests/test_composer.py
+++ b/addons/test_mass_mailing/tests/test_composer.py
@@ -17,6 +17,7 @@ class TestComposer(common.SavepointCase):
test_record = self.env['res.partner'].create({'name': 'Mass Mail Partner'})
mass_mail_record = self.env['mail.mass_mailing'].sudo(test_user).create({
'name': 'Test',
+ 'subject': 'Test',
'state': 'draft',
'mailing_model_id': self.env.ref('base.model_res_partner').id,
})
diff --git a/addons/test_mass_mailing/tests/test_link.py b/addons/test_mass_mailing/tests/test_link.py
index 99819944d82..bbe731413bf 100644
--- a/addons/test_mass_mailing/tests/test_link.py
+++ b/addons/test_mass_mailing/tests/test_link.py
@@ -45,7 +45,7 @@ class TestLinkTracker(common.MassMailingCase):
@users('marketing')
def test_add_link_mail_stat(self):
- mailing = self.env['mail.mass_mailing'].create({'name': 'Test Mailing'})
+ mailing = self.env['mail.mass_mailing'].create({'name': 'Test Mailing', "subject": "Hi!"})
code = self.link.code
self.assertEqual(self.link.count, 1)
stat = self.env['mail.mail.statistics'].create({'mass_mailing_id': mailing.id})
diff --git a/addons/test_mass_mailing/tests/test_mail_auto_blacklist.py b/addons/test_mass_mailing/tests/test_mail_auto_blacklist.py
index 1cf31dab5d3..b4a679d907e 100644
--- a/addons/test_mass_mailing/tests/test_mail_auto_blacklist.py
+++ b/addons/test_mass_mailing/tests/test_mail_auto_blacklist.py
@@ -49,6 +49,7 @@ class TestAutoBlacklist(common.TransactionCase):
# create mass mailing record
self.mass_mailing = mass_mailing.create({
'name': 'test',
+ 'subject': 'Booooounce!',
'mailing_domain': [('id', 'in',
[self.mailing_contact_1.id])],
'body_html': 'This is a bounced mail for auto blacklist demo'})
diff --git a/addons/test_mass_mailing/tests/test_mass_mailing.py b/addons/test_mass_mailing/tests/test_mass_mailing.py
index c18d19a6bea..28ba0596e6b 100644
--- a/addons/test_mass_mailing/tests/test_mass_mailing.py
+++ b/addons/test_mass_mailing/tests/test_mass_mailing.py
@@ -34,6 +34,7 @@ class TestMassMail(MassMailingCase):
_url = 'https://www.example.com/foo/bar?baz=qux'
mailing = self.env['mail.mass_mailing'].create({
'name': 'TestMailing',
+ 'subject': 'Test',
'medium_id': self.test_medium.id,
'body_html': '<p>Hello <a role="button" href="%s">${object.name}</a><a role="button" href="/unsubscribe_from_list">Unsubscribe</a></p>' % _url,
'reply_to_mode': 'email',
@@ -85,6 +86,7 @@ class TestAccessRights(TransactionCase):
# create mass mailing record
self.mass_mailing = mass_mailing.create({
'name': 'test',
+ 'subject': 'Blacklisted',
'mailing_domain': [('id', 'in',
[self.mailing_contact_1.id, self.mailing_contact_2.id, self.mailing_contact_3.id,
self.mailing_contact_4.id, self.mailing_contact_5.id])],
@@ -134,6 +136,7 @@ class TestAccessRights(TransactionCase):
# create mass mailing record
self.mass_mailing = mass_mailing.create({
'name': 'One',
+ 'subject': 'One',
'mailing_model_id': self.env['ir.model']._get(mass_mailing_contacts).id,
'body_html': 'This is mass mail marketing demo'})
self.mass_mailing.contact_list_ids = [mailing_list_1.id]
@@ -189,6 +192,7 @@ class TestAccessRights(TransactionCase):
# create mass mailing record
self.mass_mailing = mass_mailing.create({
'name': 'One',
+ 'subject': 'One',
'mailing_model_id': self.env['ir.model']._get(mass_mailing_contacts).id,
'body_html': 'This is mass mail marketing demo'})
self.mass_mailing.contact_list_ids = [mailing_list_1.id, mailing_list_2.id]
@@ -238,6 +242,7 @@ class TestAccessRights(TransactionCase):
# create mass mailing record
self.mass_mailing = mass_mailing.create({
'name': 'One',
+ 'subject': 'One',
'mailing_model_id': self.env['ir.model']._get(mass_mailing_contacts).id,
'body_html': 'This is mass mail marketing demo'})
self.mass_mailing.contact_list_ids = [mailing_list_1.id]
@@ -286,6 +291,7 @@ class TestOnResPartner(TransactionCase):
# create mass mailing record
self.mass_mailing = mass_mailing.create({
'name': 'One',
+ 'subject': 'One',
'mailing_domain': [('id', 'in', [partner_a.id, partner_b.id])],
'body_html': 'This is mass mail marketing demo'})
self.mass_mailing.mailing_model_real = 'res.partner'
diff --git a/addons/test_mass_mailing/tests/test_performance.py b/addons/test_mass_mailing/tests/test_performance.py
index f69f2e74966..963bc650404 100644
--- a/addons/test_mass_mailing/tests/test_performance.py
+++ b/addons/test_mass_mailing/tests/test_performance.py
@@ -43,6 +43,7 @@ class TestMassMailPerformance(TestMassMailPerformanceBase):
def test_send_mailing(self):
mailing = self.env['mail.mass_mailing'].create({
'name': 'Test',
+ 'subject': 'Test',
'body_html': '<p>Hello <a role="button" href="https://www.example.com/foo/bar?baz=qux">quux</a><a role="button" href="/unsubscribe_from_list">Unsubscribe</a></p>',
'reply_to_mode': 'email',
'mailing_model_id': self.ref('test_mass_mailing.model_mass_mail_test'),
@@ -80,6 +81,7 @@ class TestMassMailBlPerformance(TestMassMailPerformanceBase):
def test_send_mailing_w_bl(self):
mailing = self.env['mail.mass_mailing'].create({
'name': 'Test',
+ 'subject': 'Test',
'body_html': '<p>Hello <a role="button" href="https://www.example.com/foo/bar?baz=qux">quux</a><a role="button" href="/unsubscribe_from_list">Unsubscribe</a></p>',
'reply_to_mode': 'email',
'mailing_model_id': self.ref('test_mass_mailing.model_mass_mail_test_bl'),
diff --git a/addons/test_website/data/test_website_data.xml b/addons/test_website/data/test_website_data.xml
index df827d542a3..0d829aaab84 100644
--- a/addons/test_website/data/test_website_data.xml
+++ b/addons/test_website/data/test_website_data.xml
@@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
+
+ <!-- RECORDS FOR RESET VIEWS TESTS -->
<record id="test_view" model="ir.ui.view">
<field name="name">Test View</field>
<field name="type">qweb</field>
@@ -50,5 +52,11 @@
<p>placeholder</p>
</xpath>
</template>
+
+ <!-- RECORDS FOR MODULE OPERATION TESTS -->
+ <template id="update_module_base_view">
+ <div>I am a base view</div>
+ </template>
+
</data>
</odoo>
diff --git a/addons/test_website/static/src/js/reset_views.test.js b/addons/test_website/static/src/js/reset_views.test.js
index 4719ad271d5..bc323eaec2f 100644
--- a/addons/test_website/static/src/js/reset_views.test.js
+++ b/addons/test_website/static/src/js/reset_views.test.js
@@ -21,7 +21,7 @@ tour.register('test_reset_page_view_complete_flow_part1', {
},
{
content: "save the page",
- extra_trigger: '#oe_structure_test_website_page .s_cover',
+ extra_trigger: '#oe_structure_test_website_page.o_dirty',
trigger: "#web_editor-top-edit button[data-action=save]",
},
// 2. Edit that COW'd view in the HTML editor to break it.
@@ -77,7 +77,7 @@ tour.register('test_reset_page_view_complete_flow_part2', {
},
{
content: "select oe_structure view",
- trigger: '#ace-view-list',
+ trigger: '#s2id_ace-view-list', // use select2 version
run: function () {
var viewId = $('#ace-view-list option:contains("oe_structure")').val();
$('#ace-view-list').val(viewId).trigger('change');
diff --git a/addons/test_website/tests/__init__.py b/addons/test_website/tests/__init__.py
index 4c32d5f1959..57d8fcd0c25 100644
--- a/addons/test_website/tests/__init__.py
+++ b/addons/test_website/tests/__init__.py
@@ -2,3 +2,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_reset_views
+from . import test_views_during_module_operation
diff --git a/addons/test_website/tests/test_reset_views.py b/addons/test_website/tests/test_reset_views.py
index 930d3b00a42..047b1abe3cd 100644
--- a/addons/test_website/tests/test_reset_views.py
+++ b/addons/test_website/tests/test_reset_views.py
@@ -1,6 +1,5 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import re
-import unittest
import odoo.tests
from odoo.tools import mute_logger
@@ -95,7 +94,6 @@ class TestWebsiteResetViews(odoo.tests.HttpCase):
self.assertEqual(total_views + 1, self.View.search_count([('type', '=', 'qweb')]), "Missing COW view (2)")
self.fix_it('/test_view')
- @unittest.skip("broken test. Temporary deactivated")
@mute_logger('odoo.addons.website.models.ir_http')
def test_07_reset_page_view_complete_flow(self):
self.do_test('test_reset_page_view_complete_flow_part1')
diff --git a/addons/test_website/tests/test_views_during_module_operation.py b/addons/test_website/tests/test_views_during_module_operation.py
new file mode 100644
index 00000000000..58cf943daf4
--- /dev/null
+++ b/addons/test_website/tests/test_views_during_module_operation.py
@@ -0,0 +1,72 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.tests import TransactionCase, tagged
+
+
+@tagged('-at_install', 'post_install')
+class TestViewsDuringModuleOperation(TransactionCase):
+ def test_01_cow_views_unlink_on_module_update(self):
+ """ Ensure COW views are correctly removed during module update.
+ Not removing the view could lead to traceback:
+ - Having a view A
+ - Having a view B that inherits from a view C
+ - View B t-call view A
+ - COW view B
+ - Delete view A and B from module datas and update it
+ - Rendering view C will crash since it will render child view B that
+ t-call unexisting view A
+ """
+
+ View = self.env['ir.ui.view']
+ Imd = self.env['ir.model.data']
+
+ update_module_base_view = self.env.ref('test_website.update_module_base_view')
+ update_module_view_to_be_t_called = View.create({
+ 'name': 'View to be t-called',
+ 'type': 'qweb',
+ 'arch': '<div>I will be t-called</div>',
+ 'key': 'test_website.update_module_view_to_be_t_called',
+ })
+ update_module_child_view = View.create({
+ 'name': 'Child View',
+ 'mode': 'extension',
+ 'inherit_id': update_module_base_view.id,
+ 'arch': '''
+ <div position="inside">
+ <t t-call="test_website.update_module_view_to_be_t_called"/>
+ </div>
+ ''',
+ 'key': 'test_website.update_module_child_view',
+ })
+
+ # Create IMD so when updating the module the views will be removed (not found in file)
+ Imd.create({
+ 'module': 'test_website',
+ 'name': 'update_module_view_to_be_t_called',
+ 'model': 'ir.ui.view',
+ 'res_id': update_module_view_to_be_t_called.id,
+ })
+ Imd.create({
+ 'module': 'test_website',
+ 'name': 'update_module_child_view',
+ 'model': 'ir.ui.view',
+ 'res_id': update_module_child_view.id,
+ })
+
+ # Trigger COW on child view
+ update_module_child_view.with_context(website_id=1).write({'name': 'Child View (W1)'})
+
+ # Ensure views are correctly setup
+ self.assertEquals(View.search_count([('type', '=', 'qweb'), ('key', '=', update_module_child_view.key)]), 2)
+ self.assertTrue(self.env.ref(update_module_view_to_be_t_called.key))
+ self.assertTrue(self.env.ref(update_module_base_view.key))
+
+ # Update the module
+ test_website_module = self.env['ir.module.module'].search([('name', '=', 'test_website')])
+ test_website_module.button_immediate_upgrade()
+
+ # Ensure generic views got removed
+ self.assertFalse(self.env.ref('test_website.update_module_view_to_be_t_called', raise_if_not_found=False))
+ # Ensure specific COW views got removed
+ self.assertEquals(View.search_count([('type', '=', 'qweb'), ('key', '=', 'test_website.update_module_child_view')]), 0)
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 d00b3a49eb2..8d13f4d9679 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
@@ -535,6 +535,9 @@ ActionManager.include({
return newController(controller.jsID);
} else {
return Promise.resolve(controller.widget.willRestore()).then(function () {
+ viewOptions = _.extend({}, viewOptions, {
+ breadcrumbs: self._getBreadcrumbs(self.controllerStack.slice(0, index)),
+ });
return controller.widget.reload(viewOptions).then(function () {
return controller;
});
diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js
index a87ff3e4436..7439c36037f 100644
--- a/addons/web/static/src/js/fields/basic_fields.js
+++ b/addons/web/static/src/js/fields/basic_fields.js
@@ -1330,7 +1330,9 @@ var CopyClipboard = {
*/
destroy: function () {
this._super.apply(this, arguments);
- this.clipboard.destroy();
+ if (this.clipboard) {
+ this.clipboard.destroy();
+ }
},
//--------------------------------------------------------------------------
@@ -1345,8 +1347,8 @@ var CopyClipboard = {
var $clipboardBtn = this.$('.o_clipboard_button');
$clipboardBtn.tooltip({title: _t('Copied !'), trigger: 'manual', placement: 'right'});
this.clipboard = new ClipboardJS($clipboardBtn[0], {
- text: function (_) {
- return self.value.trim();
+ text: function () {
+ return self.value.trim();
},
// Container added because of Bootstrap modal that give the focus to another element.
// We need to give to correct focus to ClipboardJS (see in ClipboardJS doc)
@@ -1362,40 +1364,31 @@ var CopyClipboard = {
});
});
},
-};
-
-var TextCopyClipboard = FieldText.extend(CopyClipboard, {
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
/**
* @override
*/
- _render: function() {
+ _render: function () {
this._super.apply(this, arguments);
this.$el.addClass('o_field_copy');
- this.$el.append($(qweb.render('CopyClipboardText')));
- this._initClipboard();
- }
-});
-
-var CharCopyClipboard = FieldChar.extend(CopyClipboard, {
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
+ },
/**
* @override
*/
- _render: function() {
+ _renderReadonly: function () {
this._super.apply(this, arguments);
- this.$el.addClass('o_field_copy');
- this.$el.append($(qweb.render('CopyClipboardChar')));
- this._initClipboard();
+ if (this.value) {
+ this.$el.append($(qweb.render(this.clipboardTemplate)));
+ this._initClipboard();
+ }
}
+};
+
+var TextCopyClipboard = FieldText.extend(CopyClipboard, {
+ clipboardTemplate: 'CopyClipboardText',
+});
+
+var CharCopyClipboard = FieldChar.extend(CopyClipboard, {
+ clipboardTemplate: 'CopyClipboardChar',
});
var AbstractFieldBinary = AbstractField.extend({
diff --git a/addons/web/static/src/js/fields/field_registry.js b/addons/web/static/src/js/fields/field_registry.js
index 0e0ba3dc117..cd4c92087eb 100644
--- a/addons/web/static/src/js/fields/field_registry.js
+++ b/addons/web/static/src/js/fields/field_registry.js
@@ -44,8 +44,9 @@ registry
.add('priority', basic_fields.PriorityWidget)
.add('attachment_image', basic_fields.AttachmentImage)
.add('label_selection', basic_fields.LabelSelection)
+ .add('kanban_label_selection', basic_fields.LabelSelection) // deprecated, use label_selection
.add('state_selection', basic_fields.StateSelectionWidget)
- .add('kanban_state_selection', basic_fields.StateSelectionWidget)
+ .add('kanban_state_selection', basic_fields.StateSelectionWidget) // deprecated, use state_selection
.add('boolean_favorite', basic_fields.FavoriteWidget)
.add('boolean_button', basic_fields.FieldBooleanButton)
.add('boolean_toggle', basic_fields.BooleanToggle)
diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js
index 469cedb8ff1..15b7975aa42 100644
--- a/addons/web/static/src/js/fields/relational_fields.js
+++ b/addons/web/static/src/js/fields/relational_fields.js
@@ -242,7 +242,7 @@ var FieldMany2One = AbstractField.extend({
_bindAutoComplete: function () {
var self = this;
// avoid ignoring autocomplete="off" by obfuscating placeholder, see #30439
- if (this.$input.attr('placeholder')) {
+ if ($.browser.chrome && this.$input.attr('placeholder')) {
this.$input.attr('placeholder', function (index, val) {
return val.split('').join('\ufeff');
});
@@ -1901,6 +1901,7 @@ var FieldMany2ManyTags = AbstractField.extend({
}
this.colorField = this.nodeOptions.color_field;
+ this.hasDropdown = false;
},
//--------------------------------------------------------------------------
@@ -1969,6 +1970,7 @@ var FieldMany2ManyTags = AbstractField.extend({
return {
colorField: this.colorField,
elements: elements,
+ hasDropdown: this.hasDropdown,
readonly: this.mode === "readonly",
};
},
@@ -2080,6 +2082,14 @@ var FormFieldMany2ManyTags = FieldMany2ManyTags.extend({
'mousedown .o_colorpicker a': '_onUpdateColor',
'mousedown .o_colorpicker .o_hide_in_kanban': '_onUpdateColor',
}),
+ /**
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+
+ this.hasDropdown = !!this.colorField;
+ },
//--------------------------------------------------------------------------
// Handlers
diff --git a/addons/web/static/src/js/views/abstract_controller.js b/addons/web/static/src/js/views/abstract_controller.js
index 0c251874480..965ce404a64 100644
--- a/addons/web/static/src/js/views/abstract_controller.js
+++ b/addons/web/static/src/js/views/abstract_controller.js
@@ -254,7 +254,7 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
return;
}
self.renderer.setLocalState(localState);
- return self._update(state);
+ return self._update(state, params);
});
});
},
@@ -415,12 +415,15 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
*
* @private
* @param {Object} state the state given by the model
+ * @param {Object} [params]
+ * @param {Object[]} [params.breadcrumbs]
* @returns {Promise}
*/
- _update: function () {
+ _update: function (state, params) {
// AAB: update the control panel -> this will be moved elsewhere at some point
var cpContent = _.extend({}, this.controlPanelElements);
this.updateControlPanel({
+ breadcrumbs: params && params.breadcrumbs,
cp_content: cpContent,
title: this.getTitle(),
});
diff --git a/addons/web/static/src/js/views/control_panel/control_panel_controller.js b/addons/web/static/src/js/views/control_panel/control_panel_controller.js
index 913c226ba85..dc8fce9f5d1 100644
--- a/addons/web/static/src/js/views/control_panel/control_panel_controller.js
+++ b/addons/web/static/src/js/views/control_panel/control_panel_controller.js
@@ -17,6 +17,7 @@ var ControlPanelController = mvc.Controller.extend({
activate_time_range: '_onActivateTimeRange',
autocompletion_filter: '_onAutoCompletionFilter',
reload: '_onReload',
+ reset: '_onReset',
},
/**
@@ -244,6 +245,15 @@ var ControlPanelController = mvc.Controller.extend({
ev.stopPropagation();
this.trigger_up('search', this.model.getQuery());
},
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onReset: function (ev) {
+ ev.stopPropagation();
+ var state = this.model.get();
+ this.renderer.updateState(state);
+ },
});
return ControlPanelController;
diff --git a/addons/web/static/src/js/views/control_panel/search/search_bar.js b/addons/web/static/src/js/views/control_panel/search/search_bar.js
index 4c2e71a378f..6248489922c 100644
--- a/addons/web/static/src/js/views/control_panel/search/search_bar.js
+++ b/addons/web/static/src/js/views/control_panel/search/search_bar.js
@@ -187,10 +187,16 @@ var SearchBar = Widget.extend({
*/
_onAutoCompleteSelected: function (e, ui) {
e.preventDefault();
- var filter = ui.item.facet.filter;
+ var facet = ui.item.facet;
+ if (!facet) {
+ // this happens when selecting "(no result)" item
+ this.trigger_up('reset');
+ return;
+ }
+ var filter = facet.filter;
if (filter.type === 'field') {
var values = filter.autoCompleteValues;
- values.push(ui.item.facet.values[0]);
+ values.push(facet.values[0]);
this.trigger_up('autocompletion_filter', {
filterId: filter.id,
autoCompleteValues: values,
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 eda15f706d3..c69632129cd 100644
--- a/addons/web/static/src/js/views/kanban/kanban_renderer.js
+++ b/addons/web/static/src/js/views/kanban/kanban_renderer.js
@@ -249,7 +249,6 @@ var KanbanRenderer = BasicRenderer.extend({
*/
updateState: function (state) {
this._setState(state);
- this._toggleNoContentHelper();
return this._super.apply(this, arguments);
},
diff --git a/addons/web/static/src/js/views/list/list_editable_renderer.js b/addons/web/static/src/js/views/list/list_editable_renderer.js
index 4619cb86e66..cb652cb968b 100644
--- a/addons/web/static/src/js/views/list/list_editable_renderer.js
+++ b/addons/web/static/src/js/views/list/list_editable_renderer.js
@@ -285,6 +285,8 @@ ListRenderer.include({
this.currentRow = null;
}
+ // destroy widgets first
+ this._destroyFieldWidgets(recordID);
// remove the row
var $row = this.$('.o_data_row:nth(' + rowIndex + ')');
if (this.state.count >= 4) {
@@ -292,8 +294,6 @@ ListRenderer.include({
} else {
$row.replaceWith(this._renderEmptyRow());
}
-
- this._destroyFieldWidgets(recordID);
},
/**
* Updates the already rendered row associated to the given recordID so that
diff --git a/addons/web/static/src/js/views/pivot/pivot_model.js b/addons/web/static/src/js/views/pivot/pivot_model.js
index a4a2bae8fb0..989eaa448a9 100644
--- a/addons/web/static/src/js/views/pivot/pivot_model.js
+++ b/addons/web/static/src/js/views/pivot/pivot_model.js
@@ -992,7 +992,6 @@ var PivotModel = AbstractModel.extend({
var groupBys;
var m;
-
for (var i = 0; i < rowGroupBys.length + 1; i++) {
for (var j = 0; j < colGroupBys.length + 1; j++) {
for (var k = 0; k < data[index].length; k++) {
diff --git a/addons/web/static/src/js/widgets/auto_complete.js b/addons/web/static/src/js/widgets/auto_complete.js
index 6dd78f8f730..4da54cf8b5c 100644
--- a/addons/web/static/src/js/widgets/auto_complete.js
+++ b/addons/web/static/src/js/widgets/auto_complete.js
@@ -201,10 +201,8 @@ return Widget.extend({
this.current_result = $li.data('result');
},
select_item: function (ev) {
- if (this.current_result.facet) {
- this.select(ev, {item: {facet: this.current_result.facet}});
- this.close();
- }
+ this.select(ev, {item: {facet: this.current_result.facet}});
+ this.close();
},
show: function () {
this.$el.show();
diff --git a/addons/web/static/src/scss/fields.scss b/addons/web/static/src/scss/fields.scss
index 27d0d13770e..55559546e74 100644
--- a/addons/web/static/src/scss/fields.scss
+++ b/addons/web/static/src/scss/fields.scss
@@ -178,7 +178,7 @@
}
}
}
- }
+ }
.o_tag_error {
box-shadow: inset 0 0 0 2px #FF0000;
@@ -443,7 +443,10 @@
}
}
}
+}
+span.o_field_copy:empty {
+ border: none;
}
button.o_field_float_toggle {
diff --git a/addons/web/static/src/scss/form_view.scss b/addons/web/static/src/scss/form_view.scss
index 0645e00544e..812e6cbd6cc 100644
--- a/addons/web/static/src/scss/form_view.scss
+++ b/addons/web/static/src/scss/form_view.scss
@@ -81,16 +81,10 @@
.o_form_uri {
display: inline-block;
+ color: $link-color;
- color: $o-main-text-color;
- &:first-line {
- color: $link-color;
- }
&:hover {
- color: darken($o-main-text-color, 1%); // hack required on chrome
- &:first-line {
- color: darken($link-color, 15%);
- }
+ color: darken($link-color, 15%);
}
}
diff --git a/addons/web/static/src/scss/list_view.scss b/addons/web/static/src/scss/list_view.scss
index 85b9b9f6f07..55f5b755df1 100644
--- a/addons/web/static/src/scss/list_view.scss
+++ b/addons/web/static/src/scss/list_view.scss
@@ -137,6 +137,7 @@
white-space: pre-wrap;
}
+ .o_data_cell.o_text_overflow,
.o_data_cell.o_invisible_modifier {
// Override display: none to keep table cells aligned. Note: cannot use
// 'initial' as it will force them to 'inline', not 'table-cell'.
diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml
index 8126fcf11c4..7a6bca6aa7a 100644
--- a/addons/web/static/src/xml/base.xml
+++ b/addons/web/static/src/xml/base.xml
@@ -846,11 +846,11 @@
<t t-foreach="elements" t-as="el">
<t t-set="color" t-value="el[colorField] || 0"/>
<t t-set="colornames" t-value="['No color', 'Red', 'Orange', 'Yellow', 'Light blue', 'Dark purple', 'Salmon pink', 'Medium blue', 'Dark blue', 'Fushia', 'Green', 'Purple']"/>
- <div t-attf-class="badge badge-pill dropdown o_tag_color_#{color}" t-att-data-color="color" t-att-data-index="el_index" t-att-data-id="el.id" t-attf-title="Tag color: #{colornames[color]}">
+ <div t-attf-class="badge badge-pill #{hasDropdown ? 'dropdown' : ''} o_tag_color_#{color}" t-att-data-color="color" t-att-data-index="el_index" t-att-data-id="el.id" t-attf-title="Tag color: #{colornames[color]}">
<t t-set="_badge_text">
<span class="o_badge_text" t-att-title="el.display_name"><span role="img" t-attf-aria-label="Tag color: #{colornames[color]}"/><t t-esc="el.display_name"/></span>
</t>
- <t t-if="colorField">
+ <t t-if="hasDropdown">
<a role="button" href="#" class="dropdown-toggle o-no-caret" data-toggle="dropdown" aria-expanded="false">
<t t-raw="_badge_text"/>
</a>
diff --git a/addons/web/static/tests/chrome/action_manager_tests.js b/addons/web/static/tests/chrome/action_manager_tests.js
index bef2057a076..45ac864aa8e 100644
--- a/addons/web/static/tests/chrome/action_manager_tests.js
+++ b/addons/web/static/tests/chrome/action_manager_tests.js
@@ -2262,7 +2262,7 @@ QUnit.module('ActionManager', {
});
QUnit.test('breadcrumbs are updated when switching between views', async function (assert) {
- assert.expect(10);
+ assert.expect(15);
var actionManager = await createActionManager({
actions: this.actions,
@@ -2283,6 +2283,21 @@ QUnit.module('ActionManager', {
assert.strictEqual($('.o_control_panel .breadcrumb-item').text(), 'Partners',
"breadcrumbs should still display the display_name of the action");
+ // open a record in form view
+ await testUtils.dom.click(actionManager.$('.o_kanban_view .o_kanban_record:first'));
+ await testUtils.nextTick();
+ assert.strictEqual($('.o_control_panel .breadcrumb-item').length, 2,
+ "there should be two controllers in the breadcrumbs");
+ assert.strictEqual($('.o_control_panel .breadcrumb-item:last').text(), 'First record',
+ "breadcrumbs should contain the display_name of the opened record");
+
+ // go back to kanban view using the breadcrumbs
+ await testUtils.dom.click($('.o_control_panel .breadcrumb a'));
+ assert.strictEqual($('.o_control_panel .breadcrumb-item').length, 1,
+ "there should be one controller in the breadcrumbs");
+ assert.strictEqual($('.o_control_panel .breadcrumb-item').text(), 'Partners',
+ "breadcrumbs should display the display_name of the action");
+
// switch back to list view
await testUtils.dom.click($('.o_control_panel .o_cp_switch_list'));
assert.strictEqual($('.o_control_panel .breadcrumb-item').length, 1,
@@ -2299,6 +2314,8 @@ QUnit.module('ActionManager', {
// go back to list view using the breadcrumbs
await testUtils.dom.click($('.o_control_panel .breadcrumb a'));
+ assert.containsOnce(actionManager, '.o_list_view',
+ "should be back on list view");
assert.strictEqual($('.o_control_panel .breadcrumb-item').length, 1,
"there should be one controller in the breadcrumbs");
assert.strictEqual($('.o_control_panel .breadcrumb-item').text(), 'Partners',
diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js
index 223e1e5764c..a7d3582b5ca 100644
--- a/addons/web/static/tests/fields/basic_fields_tests.js
+++ b/addons/web/static/tests/fields/basic_fields_tests.js
@@ -1409,45 +1409,79 @@ QUnit.module('basic_fields', {
list.destroy();
});
- QUnit.module('copy_clipboard', {
- beforeEach: function () {
- this.data = {
- partner: {
- fields: {
- script_external: {string: "Script External", type: "text"},
- web_page: {string: "Web page link", type: "char"}
- },
- records: [{
- id: 1,
- script_external:'Random Text',
- web_page: 'web page links'
- },],
- },
- };
- }
- });
+ QUnit.module('CopyClipboard');
QUnit.test('Char & Text Fields: Copy to clipboard button', async function (assert) {
assert.expect(2);
- var done = assert.async();
- testUtils.createView({
+
+ var form = await createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<sheet>' +
'<div>' +
- '<field name="script_external" widget="CopyClipboardText"/>' +
- '<field name="web_page" widget="CopyClipboardChar"/>' +
+ '<field name="txt" widget="CopyClipboardText"/>' +
+ '<field name="foo" widget="CopyClipboardChar"/>' +
'</div>' +
'</sheet>' +
'</form>',
- }).then(function (form) {
- assert.containsOnce(form, '.o_clipboard_button.o_btn_text_copy',"Should have copy button on text type field");
- assert.containsOnce(form, '.o_clipboard_button.o_btn_char_copy',"Should have copy button on char type field");
- form.destroy();
- done();
+ res_id: 1,
+ });
+
+ assert.containsOnce(form, '.o_clipboard_button.o_btn_text_copy',"Should have copy button on text type field");
+ assert.containsOnce(form, '.o_clipboard_button.o_btn_char_copy',"Should have copy button on char type field");
+
+ form.destroy();
+ });
+
+ QUnit.test('CopyClipboard widget on unset field', async function (assert) {
+ assert.expect(1);
+
+ this.data.partner.records[0].foo = false;
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="foo" widget="CopyClipboardChar" />' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ assert.containsNone(form, '.o_field_copy[name="foo"] .o_clipboard_button',
+ "foo (unset) should not contain a button");
+
+ form.destroy();
+ });
+
+ QUnit.test('CopyClipboard widget on readonly unset fields in create mode', async function (assert) {
+ assert.expect(1);
+
+ this.data.partner.fields.display_name.readonly = true;
+
+ var form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="display_name" widget="CopyClipboardChar" />' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
});
+
+ assert.containsNone(form, '.o_field_copy[name="display_name"] .o_clipboard_button',
+ "the readonly unset field should not contain a button");
+
+ form.destroy();
});
QUnit.module('FieldText');
diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js
index af64afbb9e4..e8d89aaf00d 100644
--- a/addons/web/static/tests/fields/relational_fields_tests.js
+++ b/addons/web/static/tests/fields/relational_fields_tests.js
@@ -1265,6 +1265,30 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('fieldmany2many tags in tree view', async function (assert) {
+ assert.expect(3);
+
+ this.data.partner.records[0].timmy = [12, 14];
+ var list = await createView({
+ View: ListView,
+ model: 'partner',
+ data: this.data,
+ arch: '<tree string="Partners">' +
+ '<field name="timmy" widget="many2many_tags" options="{\'color_field\': \'color\'}"/>' +
+ '</tree>',
+ });
+ assert.containsN(list, '.o_field_many2manytags .badge', 2, "there should be 2 tags");
+ assert.containsNone(list, '.badge.dropdown-toggle', "the tags should not be dropdowns");
+
+ testUtils.intercept(list, 'switch_view', function (event) {
+ assert.strictEqual(event.data.view_type, "form", "should switch to form view");
+ });
+ // click on the tag: should do nothing and open the form view
+ testUtils.dom.click(list.$('.o_field_many2manytags .badge:first'));
+
+ list.destroy();
+ });
+
QUnit.test('fieldmany2many tags view a domain', async function (assert) {
assert.expect(7);
diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js
index 956578f2bf8..fbe4acff674 100644
--- a/addons/web/static/tests/views/kanban_tests.js
+++ b/addons/web/static/tests/views/kanban_tests.js
@@ -5341,6 +5341,119 @@ QUnit.module('Views', {
kanbanController.destroy();
delete fieldRegistry.map.asyncWidget;
});
+ QUnit.test('asynchronous rendering of a field widget with display attr', async function (assert) {
+ assert.expect(3);
+
+ var fooFieldDef = makeTestPromise();
+ var FieldChar = fieldRegistry.get('char');
+ fieldRegistry.add('asyncwidget', FieldChar.extend({
+ willStart: function () {
+ return fooFieldDef;
+ },
+ start: function () {
+ this.$el.html('LOADED');
+ },
+ }));
+
+ var kanbanController;
+ testUtils.createAsyncView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test"><templates><t t-name="kanban-box">' +
+ '<div><field name="foo" display="right" widget="asyncwidget"/></div>' +
+ '</t></templates></kanban>',
+ }).then(function (kanban) {
+ kanbanController = kanban;
+ });
+
+ assert.containsNone(document.body, '.o_kanban_record');
+
+ fooFieldDef.resolve();
+ await nextTick();
+ assert.strictEqual(kanbanController.$('.o_kanban_record').text(),
+ "LOADEDLOADEDLOADEDLOADED");
+ assert.hasClass(kanbanController.$('.o_kanban_record:first .o_field_char'), 'float-right');
+
+ kanbanController.destroy();
+ delete fieldRegistry.map.asyncWidget;
+ });
+
+ QUnit.test('asynchronous rendering of a widget', async function (assert) {
+ assert.expect(2);
+
+ var widgetDef = makeTestPromise();
+ widgetRegistry.add('asyncwidget', Widget.extend({
+ willStart: function () {
+ return widgetDef;
+ },
+ start: function () {
+ this.$el.html('LOADED');
+ },
+ }));
+
+ var kanbanController;
+ testUtils.createAsyncView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test"><templates><t t-name="kanban-box">' +
+ '<div><widget name="asyncwidget"/></div>' +
+ '</t></templates></kanban>',
+ }).then(function (kanban) {
+ kanbanController = kanban;
+ });
+
+ assert.containsNone(document.body, '.o_kanban_record');
+
+ widgetDef.resolve();
+ await nextTick();
+ assert.strictEqual(kanbanController.$('.o_kanban_record .o_widget').text(),
+ "LOADEDLOADEDLOADEDLOADED");
+
+ kanbanController.destroy();
+ delete widgetRegistry.map.asyncWidget;
+ });
+
+ QUnit.test('update kanban with asynchronous field widget', async function (assert) {
+ assert.expect(3);
+
+ var fooFieldDef = makeTestPromise();
+ var FieldChar = fieldRegistry.get('char');
+ fieldRegistry.add('asyncwidget', FieldChar.extend({
+ willStart: function () {
+ return fooFieldDef;
+ },
+ start: function () {
+ this.$el.html('LOADED');
+ },
+ }));
+
+ var kanban = await testUtils.createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban class="o_kanban_test"><templates><t t-name="kanban-box">' +
+ '<div><field name="foo" widget="asyncwidget"/></div>' +
+ '</t></templates></kanban>',
+ domain: [['id', '=', '0']], // no record matches this domain
+ });
+
+ assert.containsNone(kanban, '.o_kanban_record:not(.o_kanban_ghost)');
+
+ kanban.update({domain: []}); // this rendering will be async
+
+ assert.containsNone(kanban, '.o_kanban_record:not(.o_kanban_ghost)');
+
+ fooFieldDef.resolve();
+ await nextTick();
+
+ assert.strictEqual(kanban.$('.o_kanban_record').text(),
+ "LOADEDLOADEDLOADEDLOADED");
+
+ kanban.destroy();
+ delete widgetRegistry.map.asyncWidget;
+ });
});
});
diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js
index 3b9e5836cb5..ac34dd95028 100644
--- a/addons/web/static/tests/views/list_tests.js
+++ b/addons/web/static/tests/views/list_tests.js
@@ -275,6 +275,52 @@ QUnit.module('Views', {
list.destroy();
});
+ QUnit.test('editable list datetimepicker destroy widget', async function (assert) {
+ assert.expect(7);
+ var eventPromise = testUtils.makeTestPromise();
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top">' +
+ '<field name="date"/>' +
+ '</tree>',
+ });
+ list.$el.on({
+ 'show.datetimepicker': async function () {
+ assert.equal($('.bootstrap-datetimepicker-widget').length, 1,
+ 'The datetimepicker is open');
+
+ assert.equal(list.$('.o_data_row').length, 5,
+ 'There should be 5 rows');
+
+ assert.equal(list.$('.o_selected_row').length, 1,
+ 'One row in edit mode');
+
+ await testUtils.fields.triggerKeydown(list.$('.o_datepicker_input'), 'escape');
+
+ assert.equal(list.$('.o_data_row').length, 4,
+ 'There should be 4 rows');
+
+ assert.equal(list.$('.o_selected_row').length, 0,
+ 'No row should be in edit mode');
+
+ eventPromise.resolve();
+ }
+ });
+ assert.equal(list.$('.o_data_row').length, 4,
+ 'There should be 4 rows');
+
+ assert.equal(list.$('.o_selected_row').length, 0,
+ 'No row should be in edit mode');
+
+ await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
+
+ await eventPromise;
+ list.destroy();
+ });
+
QUnit.test('at least 4 rows are rendered, even if less data', async function (assert) {
assert.expect(1);
diff --git a/addons/web/static/tests/views/pivot_tests.js b/addons/web/static/tests/views/pivot_tests.js
index c098ecd7867..c0b46be3733 100644
--- a/addons/web/static/tests/views/pivot_tests.js
+++ b/addons/web/static/tests/views/pivot_tests.js
@@ -1096,6 +1096,48 @@ QUnit.module('Views', {
pivot.destroy();
});
+ QUnit.skip('Reload, group by columns, reload', async function (assert) {
+ assert.expect(2);
+
+ var pivot = await createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot/>',
+ });
+
+ // Set a column groupby
+ pivot.$('thead .o_pivot_header_cell_closed').click();
+ pivot.$('.o_field_selection .dropdown-item[data-field=customer]').click();
+
+ // Set a domain
+ pivot.update({domain: [['product_id', '=', 41]]});
+
+ var expectedContext = {pivot_column_groupby: ['customer'],
+ pivot_measures: ['__count'],
+ pivot_row_groupby: []};
+
+ // Check that column groupbys were not lost
+ assert.deepEqual(pivot.getContext(), expectedContext,
+ 'Column groupby not lost after first reload');
+
+ // Set a column groupby
+ pivot.$('thead .o_pivot_header_cell_closed').click();
+ pivot.$('.o_field_selection .dropdown-item[data-field=product_id]').click();
+
+ // Set a domain
+ pivot.update({domain: [['product_id', '=', 37]]});
+
+ var expectedContext = {pivot_column_groupby: ['customer', 'product_id'],
+ pivot_measures: ['__count'],
+ pivot_row_groupby: []};
+
+ assert.deepEqual(pivot.getContext(), expectedContext,
+ 'Column groupby not lost after second reload');
+
+ pivot.destroy();
+ });
+
QUnit.test('correctly uses pivot_ keys from the context', async function (assert) {
assert.expect(7);
diff --git a/addons/web/static/tests/views/search_view_tests.js b/addons/web/static/tests/views/search_view_tests.js
index e57194ef19c..f7d07f8da9d 100644
--- a/addons/web/static/tests/views/search_view_tests.js
+++ b/addons/web/static/tests/views/search_view_tests.js
@@ -1085,6 +1085,41 @@ QUnit.module('Search View', {
actionManager.destroy();
});
+ QUnit.test('selecting (no result) triggers a re-render', async function (assert) {
+ assert.expect(3);
+ var actionManager = await createActionManager({
+ actions: this.actions,
+ archs: this.archs,
+ data: this.data,
+ });
+
+ actionManager.doAction(10);
+ await testUtils.nextTick();
+
+ // 'a' key to filter nothing on bar
+ actionManager.$('.o_searchview_input').val('a');
+ actionManager.$('.o_searchview_input').trigger($.Event('keypress', { which: 65, keyCode: 65 }));
+ await testUtils.nextTick();
+ actionManager.$('.o_searchview_input').trigger($.Event('keydown', { which: $.ui.keyCode.DOWN, keyCode: $.ui.keyCode.DOWN }));
+ await testUtils.nextTick();
+ actionManager.$('.o_searchview_input').trigger($.Event('keydown', { which: $.ui.keyCode.RIGHT, keyCode: $.ui.keyCode.RIGHT }));
+ await testUtils.nextTick();
+ actionManager.$('.o_searchview_input').trigger($.Event('keydown', { which: $.ui.keyCode.DOWN, keyCode: $.ui.keyCode.DOWN }));
+ await testUtils.nextTick();
+
+ assert.strictEqual(actionManager.$('.o_searchview_autocomplete .o-selection-focus').text(), "(no result)",
+ "there should be no result for 'a' in bar");
+
+ actionManager.$('.o_searchview_input').trigger($.Event('keydown', { which: $.ui.keyCode.ENTER, keyCode: $.ui.keyCode.ENTER }));
+ await testUtils.nextTick();
+
+ assert.containsNone(actionManager, '.o_searchview_facet_label');
+ assert.strictEqual(actionManager.$('.o_searchview_input').val(), "",
+ "the search input should be re-rendered");
+
+ actionManager.destroy();
+ });
+
QUnit.module('TimeRangeMenu');
QUnit.test('time range menu stays hidden', async function (assert) {
diff --git a/addons/web_editor/models/ir_ui_view.py b/addons/web_editor/models/ir_ui_view.py
index 41de79b3c24..44f136dc471 100644
--- a/addons/web_editor/models/ir_ui_view.py
+++ b/addons/web_editor/models/ir_ui_view.py
@@ -271,5 +271,6 @@ class IrUiView(models.Model):
``bundles=True`` returns also the asset bundles
"""
user_groups = set(self.env.user.groups_id)
- views = self.with_context(active_test=False)._views_get(key, bundles=bundles)
+ View = self.with_context(active_test=False, lang=None)
+ views = View._views_get(key, bundles=bundles)
return views.filtered(lambda v: not v.groups_id or len(user_groups.intersection(v.groups_id)))
diff --git a/addons/web_editor/static/src/js/backend/field_html.js b/addons/web_editor/static/src/js/backend/field_html.js
index 185d0c84963..d3f9abfd024 100644
--- a/addons/web_editor/static/src/js/backend/field_html.js
+++ b/addons/web_editor/static/src/js/backend/field_html.js
@@ -42,6 +42,7 @@ var FieldHtml = basic_fields.DebouncedField.extend(TranslatableFieldMixin, {
*/
willStart: function () {
var self = this;
+ this.isRendered = false;
this._onUpdateIframeId = 'onLoad_' + _.uniqueId('FieldHtml');
var defAsset;
if (this.nodeOptions.cssReadonly) {
@@ -93,7 +94,7 @@ var FieldHtml = basic_fields.DebouncedField.extend(TranslatableFieldMixin, {
*/
commitChanges: function () {
var self = this;
- if (this.mode == "readonly") {
+ if (this.mode == "readonly" || !this.isRendered) {
return this._super();
}
var _super = this._super.bind(this);
@@ -166,6 +167,7 @@ var FieldHtml = basic_fields.DebouncedField.extend(TranslatableFieldMixin, {
return this.wysiwyg.attachTo(this.$target).then(function () {
self.$content = self.wysiwyg.$editor;
self._onLoadWysiwyg();
+ self.isRendered = true;
});
},
/**
diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/media.js b/addons/web_editor/static/src/js/wysiwyg/plugin/media.js
index 242d31f68b9..ce58a4eac86 100644
--- a/addons/web_editor/static/src/js/wysiwyg/plugin/media.js
+++ b/addons/web_editor/static/src/js/wysiwyg/plugin/media.js
@@ -54,7 +54,14 @@ var MediaPlugin = AbstractPlugin.extend({
this.context.invoke('editor.saveRange');
var media = this.context.invoke('editor.restoreTarget');
- var mediaDialog = new weWidgets.MediaDialog(this.options.parent, {},
+ var $mediaParent = $(media).parent();
+ if ($mediaParent.hasClass('media_iframe_video')) {
+ media = $mediaParent[0];
+ $mediaParent = $mediaParent.parent();
+ }
+ var mediaDialog = new weWidgets.MediaDialog(this.options.parent, {
+ onlyImages: $mediaParent.data('oeField') === 'image' || $mediaParent.data('oeType') === 'image',
+ },
$(media).clone()[0]
);
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/media_dialog.js b/addons/web_editor/static/src/js/wysiwyg/widgets/media_dialog.js
index 5ece0e9ee3f..cdfde911597 100644
--- a/addons/web_editor/static/src/js/wysiwyg/widgets/media_dialog.js
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/media_dialog.js
@@ -48,7 +48,7 @@ var MediaDialog = Dialog.extend({
this.$media = $(media);
this.multiImages = options.multiImages;
- var onlyImages = options.onlyImages || this.multiImages || (this.media && (this.$media.parent().data('oeField') === 'image' || this.$media.parent().data('oeType') === 'image'));
+ var onlyImages = options.onlyImages || this.multiImages;
this.noImages = options.noImages;
this.noDocuments = onlyImages || options.noDocuments;
this.noIcons = onlyImages || options.noIcons;
@@ -80,10 +80,6 @@ var MediaDialog = Dialog.extend({
this.active = this.documentDialog;
} else if (this.videoDialog && this.$media.hasClass('media_iframe_video')) {
this.active = this.videoDialog;
- } else if (this.videoDialog && this.$media.parent().hasClass('media_iframe_video')) {
- this.$media = this.$media.parent();
- this.media = this.$media[0];
- this.active = this.videoDialog;
} else if (this.iconDialog && this.$media.is('span, i')) {
this.active = this.iconDialog;
}
diff --git a/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.editor.js b/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.editor.js
index 66d8557d9c5..b63bc646bc8 100644
--- a/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.editor.js
+++ b/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.editor.js
@@ -92,6 +92,16 @@ var SnippetEditor = Widget.extend({
});
}
+ this.$target.on('transitionstart.snippet_editor, animationstart.snippet_editor', function () {
+ self._targetIsAnimated = true;
+ });
+ this.$target.on('transitionend.snippet_editor, animationend.snippet_editor', function () {
+ self._targetIsAnimated = false;
+ if (self.$el.is('.oe_active')) {
+ self.cover();
+ }
+ });
+
return Promise.all(defs);
},
/**
@@ -101,6 +111,7 @@ var SnippetEditor = Widget.extend({
this.cleanForSave();
this._super.apply(this, arguments);
this.$target.removeData('snippet-editor');
+ this.$target.off('.snippet_editor');
},
//--------------------------------------------------------------------------
@@ -132,6 +143,11 @@ var SnippetEditor = Widget.extend({
* Makes the editor overlay cover the associated snippet.
*/
cover: function () {
+ if (this._targetIsAnimated) {
+ // Do not cover a target being animated, it will be covered once the
+ // animation is completed.
+ return;
+ }
var offset = this.$target.offset();
var manipulatorOffset = this.$el.parent().offset();
offset.top -= manipulatorOffset.top;
@@ -605,7 +621,7 @@ var SnippetsMenu = Widget.extend({
// Active snippet editor on click in the page
var lastClickedElement;
this.$document.on('click.snippets_menu', '*', function (ev) {
- var srcElement = ev.srcElement || (ev.originalEvent && (ev.originalEvent.originalTarget || ev.originalEvent.target) || ev.target);
+ var srcElement = ev.target || (ev.originalEvent && (ev.originalEvent.target || ev.originalEvent.originalTarget)) || ev.srcElement;
if (lastClickedElement === srcElement || !srcElement) {
return;
}
diff --git a/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.options.js b/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.options.js
index 9ab2896540b..7ab6ea205d8 100644
--- a/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.options.js
+++ b/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.options.js
@@ -296,13 +296,11 @@ var SnippetOption = Widget.extend({
.addClass('active');
_processSelectClassElements(this.$el);
- _.each(this.$el.find('.dropdown-menu'), function (group) {
- _processSelectClassElements($(group).children());
- });
- function _processSelectClassElements($elements) {
+ function _processSelectClassElements($el) {
var maxNbClasses = -1;
- $elements.filter('[data-select-class]')
+ $el.find('[data-select-class]')
+ .addBack('[data-select-class]')
.removeClass('active')
.filter(function () {
var className = $(this).data('selectClass');
diff --git a/addons/web_editor/static/tests/field_html_tests.js b/addons/web_editor/static/tests/field_html_tests.js
index 73ef267bbf8..31855ef9ff7 100644
--- a/addons/web_editor/static/tests/field_html_tests.js
+++ b/addons/web_editor/static/tests/field_html_tests.js
@@ -491,6 +491,26 @@ QUnit.module('web_editor', {}, function () {
form.destroy();
});
+QUnit.test('save immediately before iframe is rendered in edit mode', async function (assert) {
+ assert.expect(1);
+
+ var form = await testUtils.createAsyncView({
+ View: FormView,
+ model: 'note.note',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="body" widget="html" style="height: 100px" options="{\'cssEdit\': \'template.assets\'}"/>' +
+ '</form>',
+ res_id: 1,
+ });
+ testUtils.form.clickEdit(form);
+ await testUtils.nextTick();
+ testUtils.form.clickSave(form);
+ await testUtils.nextTick();
+ assert.ok(true, "No traceback encountered. The wysiwyg was cut while not loaded.");
+ form.destroy();
+});
+
QUnit.test('use colorpicker and save', async function (assert) {
assert.expect(1);
diff --git a/addons/web_unsplash/models/__init__.py b/addons/web_unsplash/models/__init__.py
index 63a963dae32..ffce82298b0 100644
--- a/addons/web_unsplash/models/__init__.py
+++ b/addons/web_unsplash/models/__init__.py
@@ -3,3 +3,4 @@
from . import res_config_settings
from . import res_users
+from . import ir_qweb
diff --git a/addons/web_unsplash/models/ir_qweb.py b/addons/web_unsplash/models/ir_qweb.py
new file mode 100644
index 00000000000..8d581c47299
--- /dev/null
+++ b/addons/web_unsplash/models/ir_qweb.py
@@ -0,0 +1,26 @@
+from werkzeug import urls
+
+from odoo import models, api
+
+
+class Image(models.AbstractModel):
+ _inherit = 'ir.qweb.field.image'
+
+ @api.model
+ def from_html(self, model, field, element):
+ url = element.find('img').get('src')
+ url_object = urls.url_parse(url)
+
+ if url_object.path.startswith('/unsplash/'):
+ res_id = element.get('data-oe-id')
+ if res_id:
+ res_id = int(res_id)
+ res_model = model._name
+ attachment = self.env['ir.attachment'].search([
+ ('res_model', '=', res_model),
+ ('res_id', '=', res_id),
+ ('url', '=', url_object.path),
+ ], limit=1)
+ return attachment.datas
+
+ return super(Image, self).from_html(model, field, element)
diff --git a/addons/website/__init__.py b/addons/website/__init__.py
index c158f87e14f..dcd5ec789d0 100644
--- a/addons/website/__init__.py
+++ b/addons/website/__init__.py
@@ -4,3 +4,19 @@
from . import controllers
from . import models
from . import wizard
+
+import odoo
+from odoo import api, SUPERUSER_ID
+from functools import partial
+
+
+def uninstall_hook(cr, registry):
+ def rem_website_id_null(dbname):
+ db_registry = odoo.modules.registry.Registry.new(dbname)
+ with api.Environment.manage(), db_registry.cursor() as cr:
+ env = api.Environment(cr, SUPERUSER_ID, {})
+ env['ir.model.fields'].search([
+ ('name', '=', 'website_id'),
+ ('model', '=', 'res.config.settings'),
+ ]).unlink()
+ cr.after('commit', partial(rem_website_id_null, cr.dbname))
diff --git a/addons/website/__manifest__.py b/addons/website/__manifest__.py
index 8077191960d..649c092d98b 100644
--- a/addons/website/__manifest__.py
+++ b/addons/website/__manifest__.py
@@ -36,4 +36,5 @@
],
'qweb': ['static/src/xml/website.backend.xml'],
'application': True,
+ 'uninstall_hook': 'uninstall_hook',
}
diff --git a/addons/website/models/ir_http.py b/addons/website/models/ir_http.py
index 2be6c571a37..e449bea5e42 100644
--- a/addons/website/models/ir_http.py
+++ b/addons/website/models/ir_http.py
@@ -208,6 +208,10 @@ class Http(models.AbstractModel):
if isinstance(exception, QWebException):
values.update(qweb_exception=exception)
+
+ # retro compatibility to remove in 12.2
+ exception.qweb = dict(message=exception.message, expression=exception.html)
+
if type(exception.error) == odoo.exceptions.AccessError:
code = 403
diff --git a/addons/website/models/ir_ui_view.py b/addons/website/models/ir_ui_view.py
index 27fe7a66437..c4dc9182906 100644
--- a/addons/website/models/ir_ui_view.py
+++ b/addons/website/models/ir_ui_view.py
@@ -157,7 +157,12 @@ class View(models.Model):
# care of creating pages and menus.
view.with_context(website_id=website.id).write({'name': view.name})
- result = super(View, self).unlink()
+ specific_views = self.env['ir.ui.view']
+ if self and self.pool._init:
+ for view in self:
+ specific_views += view._get_specific_views()
+
+ result = super(View, self + specific_views).unlink()
self.clear_caches()
return result
diff --git a/addons/website/static/src/js/tours/rte.js b/addons/website/static/src/js/tours/rte.js
index 906f16e2924..eacc2b89e67 100644
--- a/addons/website/static/src/js/tours/rte.js
+++ b/addons/website/static/src/js/tours/rte.js
@@ -178,5 +178,25 @@ tour.register('rte_translator', {
}, {
content: "check bis: placeholder translation",
trigger: 'input[placeholder="test french placeholder"]',
+}, {
+ content: "Open customize menu",
+ trigger: "#customize-menu > .dropdown-toggle",
+}, {
+ content: "Open HTML editor",
+ trigger: "[data-action='ace']",
+}, {
+ content: "Check that the editor is not showing translated content (1)",
+ trigger: '.ace_text-layer .ace_line:contains("an HTML")',
+ run: function (actions) {
+ var lineEscapedText = $(this.$anchor.text()).text();
+ if (lineEscapedText !== "&lt;b&gt;&lt;/b&gt; is an HTML&nbsp;tag &amp; is empty") {
+ console.error('The HTML editor should display the correct untranslated content');
+ $('body').addClass('rte_translator_error');
+ }
+ },
+}, {
+ content: "Check that the editor is not showing translated content (2)",
+ trigger: 'body:not(.rte_translator_error)',
+ run: function () {},
}]);
});
diff --git a/addons/website/static/src/scss/website.edit_mode.scss b/addons/website/static/src/scss/website.edit_mode.scss
index 664dcf037d0..00a6d41a261 100644
--- a/addons/website/static/src/scss/website.edit_mode.scss
+++ b/addons/website/static/src/scss/website.edit_mode.scss
@@ -218,6 +218,9 @@
display: inline-block;
}
.btn, &.btn {
+ -webkit-user-select: auto;
+ -moz-user-select: auto;
+ -ms-user-select: auto;
user-select: auto;
cursor: text!important;
}
diff --git a/addons/website/tests/test_page.py b/addons/website/tests/test_page.py
index 1b82d92ad57..88f09bfc2ed 100644
--- a/addons/website/tests/test_page.py
+++ b/addons/website/tests/test_page.py
@@ -1,7 +1,8 @@
# coding: utf-8
-from odoo.tests import common, HttpCase
+from odoo.tests import common, HttpCase, tagged
+@tagged('-at_install', 'post_install')
class TestPage(common.TransactionCase):
def setUp(self):
super(TestPage, self).setUp()
diff --git a/addons/website/tests/test_views.py b/addons/website/tests/test_views.py
index 0416bc72bf7..bb847ba9f6a 100644
--- a/addons/website/tests/test_views.py
+++ b/addons/website/tests/test_views.py
@@ -10,7 +10,7 @@ import unittest
from lxml import etree as ET, html
from lxml.html import builder as h
-from odoo.tests import common, HttpCase
+from odoo.tests import common, HttpCase, tagged
def attrs(**kwargs):
@@ -264,6 +264,7 @@ class TestViewSaving(common.TransactionCase):
)
+@tagged('-at_install', 'post_install')
class TestCowViewSaving(common.TransactionCase):
def setUp(self):
super(TestCowViewSaving, self).setUp()
diff --git a/addons/website_blog/views/website_blog_templates.xml b/addons/website_blog/views/website_blog_templates.xml
index 0d92f3fefdf..8e92cab712e 100644
--- a/addons/website_blog/views/website_blog_templates.xml
+++ b/addons/website_blog/views/website_blog_templates.xml
@@ -206,6 +206,9 @@
<!-- Option: Blog Post List: grid layout -->
<template id="opt_blog_post_grid_layout" name="Grid view"
customize_show="True" active="False" inherit_id="website_blog.blog_post_short">
+ <xpath expr="//div[@id='main_column']" position="attributes">
+ <attribute name="class" separator=" " add="d-flex" />
+ </xpath>
<xpath expr="//div[@name='blog_post']" position="attributes">
<attribute name="class" add="col-md-5 o_grid" separator=" "/>
</xpath>
diff --git a/addons/website_event_sale/models/__init__.py b/addons/website_event_sale/models/__init__.py
index 1bd7829ab86..a9c94cbe9ae 100644
--- a/addons/website_event_sale/models/__init__.py
+++ b/addons/website_event_sale/models/__init__.py
@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
from . import product
+from . import product_pricelist
from . import sale_order
from . import website
diff --git a/addons/website_event_sale/models/product_pricelist.py b/addons/website_event_sale/models/product_pricelist.py
new file mode 100644
index 00000000000..5b0d4465759
--- /dev/null
+++ b/addons/website_event_sale/models/product_pricelist.py
@@ -0,0 +1,23 @@
+# -*- coding: utf-8 -*-
+
+from odoo import _, api, models
+
+class PricelistItem(models.Model):
+ _inherit = "product.pricelist.item"
+
+ @api.onchange('applied_on', 'product_id', 'product_tmpl_id', 'min_quantity')
+ def _onchange_event_sale_warning(self):
+ if self.min_quantity > 0:
+ msg = ''
+ if self.applied_on == '3_global' or self.applied_on == '2_product_category':
+ msg = _("A pricelist item with a positive min. quantity will not be applied to the event tickets products.")
+ elif ((self.applied_on == '1_product' and self.product_tmpl_id.event_ok) or
+ (self.applied_on == '0_product_variant' and self.product_id.event_ok)):
+ msg = _("A pricelist item with a positive min. quantity cannot be applied to this event tickets product.")
+ if msg:
+ return {'warning':
+ {
+ 'title': _("Warning"),
+ 'message': msg
+ }
+ }
diff --git a/addons/website_forum/static/src/scss/website_forum.scss b/addons/website_forum/static/src/scss/website_forum.scss
index 8d645e26030..391adf9d792 100644
--- a/addons/website_forum/static/src/scss/website_forum.scss
+++ b/addons/website_forum/static/src/scss/website_forum.scss
@@ -218,3 +218,8 @@ img.o_forum_avatar_big {
min-height: 75px;
}
}
+
+.o_wforum_elearning_navtabs_container {
+ height: $owprofile-tabs-height;
+ background-color: #66445e;
+}
diff --git a/addons/website_forum/views/website_forum_profile.xml b/addons/website_forum/views/website_forum_profile.xml
index f30f8f47d3f..1afdb913731 100644
--- a/addons/website_forum/views/website_forum_profile.xml
+++ b/addons/website_forum/views/website_forum_profile.xml
@@ -10,18 +10,18 @@
</template>
<template id="user_profile_content" inherit_id="website_profile.user_profile_content">
- <xpath expr="//tr[@id='profile_abstract_info_company']" position="after">
+ <xpath expr="//table[@id='o_wprofile_sidebar_table']//tr[last()]" position="after">
<t t-if="forum and (up_votes or down_votes)">
<tr id="profile_abstract_info_company">
- <th style="width: 30%"><span class="font-weight-bold text-muted">Votes</span></th>
- <th style="width: 70%">
+ <th><small class="font-weight-bold">Votes</small></th>
+ <td>
<span>
<i class="fa fa-thumbs-up text-success" role="img" aria-label="Positive votes" title="Positive votes"/>
<span class="font-weight-bold" t-esc="up_votes"/>
<i class="fa fa-thumbs-down text-danger ml-3" role="img" aria-label="Negative votes" title="Negative votes"/>
<span class="font-weight-bold" t-esc="down_votes"/>
</span>
- </th>
+ </td>
</tr>
</t>
</xpath>
@@ -45,7 +45,7 @@
<t t-if="forum">
<div role="tabpanel" class="tab-pane" id="questions">
<div class="mb32">
- <h4><i class="fa fa-question"/> Questions</h4>
+ <h5 class="border-bottom pb-1">Questions</h5>
<t t-if="questions">
<div class="mb16" t-foreach="questions" t-as="question">
<t t-call="website_forum.display_post"/>
@@ -57,7 +57,7 @@
</div>
<t t-if="favourite">
<div class="mb32">
- <h4><i class="fa fa-star"/> Favourite Questions</h4>
+ <h5 class="border-bottom pb-1">Favourite Questions</h5>
<div t-foreach="favourite" t-as="question">
<t t-call="website_forum.display_post"/>
</div>
@@ -65,7 +65,7 @@
</t>
<t t-if="followed">
<div class="mb32">
- <h4><i class="fa fa-eye"/> Followed Questions</h4>
+ <h5 class="border-bottom pb-1">Followed Questions</h5>
<div t-foreach="followed" t-as="question" class="mb16">
<t t-call="website_forum.display_post"/>
</div>
@@ -74,7 +74,7 @@
</div>
<div role="tabpanel" class="tab-pane" id="answers">
<div class="mb32">
- <h4><i class="fa fa-reply"/> Answers</h4>
+ <h5 class="border-bottom pb-1">Answers</h5>
<t t-if="answers">
<div t-foreach="answers" t-as="answer">
<t t-call="website_forum.display_post_answer"/>
@@ -87,13 +87,13 @@
</div>
<div role="tabpanel" class="tab-pane" id="votes" t-if="uid == user.id">
<div class="mb32">
- <h4><i class="fa fa-thumbs-up"/> Votes</h4>
+ <h5 class="border-bottom pb-1">Votes</h5>
<t t-call="website_forum.user_votes"/>
</div>
</div>
<div role="tabpanel" class="tab-pane" id="activity" t-if="uid == user.id">
<div class="mb32">
- <h4><i class="fa fa-history"/> Activities</h4>
+ <h5 class="border-bottom pb-1">Activities</h5>
<t t-call="website_forum.display_activities"/>
</div>
</div>
@@ -122,7 +122,7 @@
</div>
</t>
<t t-else="">
- <span class="font-weight-bold">No activities yet!</span>
+ <p class="text-muted">No activities yet!</p>
</t>
</template>
@@ -139,7 +139,7 @@
</t>
</div>
<div class="mb16" t-if="not vote_post">
- <span class="font-weight-bold">No vote given by you yet!</span>
+ <p class="text-muted">No vote given by you yet!</p>
</div>
</template>
</data></odoo>
diff --git a/addons/website_links/static/src/js/website_links.js b/addons/website_links/static/src/js/website_links.js
index a7c49dfa86c..fd637079512 100644
--- a/addons/website_links/static/src/js/website_links.js
+++ b/addons/website_links/static/src/js/website_links.js
@@ -365,7 +365,7 @@ publicWidget.registry.websiteLinks = publicWidget.Widget.extend({
defs.push(sourceSelect.attachTo($('#source-select')));
// Recent Links Widgets
- this.recentLinks = new RecentLinks();
+ this.recentLinks = new RecentLinks(this);
defs.push(this.recentLinks.appendTo($('#o_website_links_recent_links')));
this.recentLinks.getRecentLinks('newest');
diff --git a/addons/website_profile/controllers/main.py b/addons/website_profile/controllers/main.py
index ea0ccd3f0cb..4d0e58efbe8 100644
--- a/addons/website_profile/controllers/main.py
+++ b/addons/website_profile/controllers/main.py
@@ -13,6 +13,7 @@ from odoo.osv import expression
class WebsiteProfile(http.Controller):
+ PAGER_MAX_PAGE = 5
# Profile
# ---------------------------------------------------
diff --git a/addons/website_profile/static/src/img/badge_bronze.svg b/addons/website_profile/static/src/img/badge_bronze.svg
new file mode 100644
index 00000000000..a94491e8a55
--- /dev/null
+++ b/addons/website_profile/static/src/img/badge_bronze.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300"><g fill="none"><circle cx="150" cy="150" r="150" fill="#FFF"/><path fill="#C37933" d="M150 300C67.157 300 0 232.843 0 150S67.157 0 150 0s150 67.157 150 150-67.157 150-150 150zm0-9.375c77.665 0 140.625-62.96 140.625-140.625 0-77.665-62.96-140.625-140.625-140.625C72.335 9.375 9.375 72.335 9.375 150c0 77.665 62.96 140.625 140.625 140.625zm0-14.063C80.101 276.563 23.437 219.9 23.437 150S80.102 23.437 150 23.437 276.563 80.102 276.563 150 219.899 276.563 150 276.563zm56.757-126.57l12.882-12.602c1.867-1.743 2.49-3.922 1.867-6.536-.747-2.551-2.365-4.139-4.854-4.76l-17.55-4.482 4.947-17.365c.747-2.552.156-4.73-1.773-6.535-1.805-1.93-3.983-2.52-6.535-1.774l-17.363 4.948-4.48-17.551c-.623-2.552-2.21-4.14-4.761-4.761-2.552-.685-4.73-.094-6.535 1.773L150 93.324l-12.602-12.977c-1.805-1.93-3.983-2.52-6.535-1.773-2.551.622-4.138 2.209-4.76 4.76l-4.481 17.552-17.363-4.948c-2.552-.747-4.73-.155-6.535 1.774-1.929 1.805-2.52 3.983-1.773 6.535l4.947 17.365-17.55 4.481c-2.489.623-4.107 2.21-4.854 4.761-.622 2.614 0 4.793 1.867 6.536l12.882 12.603-12.882 12.603c-1.867 1.743-2.49 3.921-1.867 6.535.747 2.552 2.365 4.14 4.854 4.762l17.55 4.481-4.947 17.365c-.747 2.552-.156 4.73 1.773 6.535 1.805 1.93 3.983 2.52 6.535 1.774l17.363-4.948 4.48 17.551c.623 2.552 2.21 4.17 4.761 4.855 2.614.622 4.792 0 6.535-1.867L150 206.755l12.602 12.884c1.245 1.369 2.832 2.053 4.761 2.053.436 0 1.027-.062 1.774-.186 2.551-.747 4.138-2.365 4.76-4.855l4.481-17.551 17.363 4.948c2.552.747 4.73.155 6.535-1.774 1.929-1.805 2.52-3.983 1.773-6.535l-4.947-17.365 17.55-4.481c2.489-.623 4.107-2.21 4.854-4.762.622-2.614 0-4.792-1.867-6.535l-12.882-12.603z"/></g></svg> \ No newline at end of file
diff --git a/addons/website_profile/static/src/img/badge_gold.svg b/addons/website_profile/static/src/img/badge_gold.svg
new file mode 100644
index 00000000000..423f63e6d8b
--- /dev/null
+++ b/addons/website_profile/static/src/img/badge_gold.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300"><g fill="none"><circle cx="150" cy="150" r="150" fill="#FFF"/><path fill="#E2BE00" d="M150 300C67.157 300 0 232.843 0 150S67.157 0 150 0s150 67.157 150 150-67.157 150-150 150zm0-9.375c77.665 0 140.625-62.96 140.625-140.625 0-77.665-62.96-140.625-140.625-140.625C72.335 9.375 9.375 72.335 9.375 150c0 77.665 62.96 140.625 140.625 140.625zm0-14.063C80.101 276.563 23.437 219.9 23.437 150S80.102 23.437 150 23.437 276.563 80.102 276.563 150 219.899 276.563 150 276.563zm56.757-126.57l12.882-12.602c1.867-1.743 2.49-3.922 1.867-6.536-.747-2.551-2.365-4.139-4.854-4.76l-17.55-4.482 4.947-17.365c.747-2.552.156-4.73-1.773-6.535-1.805-1.93-3.983-2.52-6.535-1.774l-17.363 4.948-4.48-17.551c-.623-2.552-2.21-4.14-4.761-4.761-2.552-.685-4.73-.094-6.535 1.773L150 93.324l-12.602-12.977c-1.805-1.93-3.983-2.52-6.535-1.773-2.551.622-4.138 2.209-4.76 4.76l-4.481 17.552-17.363-4.948c-2.552-.747-4.73-.155-6.535 1.774-1.929 1.805-2.52 3.983-1.773 6.535l4.947 17.365-17.55 4.481c-2.489.623-4.107 2.21-4.854 4.761-.622 2.614 0 4.793 1.867 6.536l12.882 12.603-12.882 12.603c-1.867 1.743-2.49 3.921-1.867 6.535.747 2.552 2.365 4.14 4.854 4.762l17.55 4.481-4.947 17.365c-.747 2.552-.156 4.73 1.773 6.535 1.805 1.93 3.983 2.52 6.535 1.774l17.363-4.948 4.48 17.551c.623 2.552 2.21 4.17 4.761 4.855 2.614.622 4.792 0 6.535-1.867L150 206.755l12.602 12.884c1.245 1.369 2.832 2.053 4.761 2.053.436 0 1.027-.062 1.774-.186 2.551-.747 4.138-2.365 4.76-4.855l4.481-17.551 17.363 4.948c2.552.747 4.73.155 6.535-1.774 1.929-1.805 2.52-3.983 1.773-6.535l-4.947-17.365 17.55-4.481c2.489-.623 4.107-2.21 4.854-4.762.622-2.614 0-4.792-1.867-6.535l-12.882-12.603z"/></g></svg> \ No newline at end of file
diff --git a/addons/website_profile/static/src/img/badge_silver.svg b/addons/website_profile/static/src/img/badge_silver.svg
new file mode 100644
index 00000000000..dd16d4f2bae
--- /dev/null
+++ b/addons/website_profile/static/src/img/badge_silver.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300"><g fill="none"><circle cx="150" cy="150" r="150" fill="#FFF"/><path fill="#838997" d="M150 300C67.157 300 0 232.843 0 150S67.157 0 150 0s150 67.157 150 150-67.157 150-150 150zm0-9.375c77.665 0 140.625-62.96 140.625-140.625 0-77.665-62.96-140.625-140.625-140.625C72.335 9.375 9.375 72.335 9.375 150c0 77.665 62.96 140.625 140.625 140.625zm0-14.063C80.101 276.563 23.437 219.9 23.437 150S80.102 23.437 150 23.437 276.563 80.102 276.563 150 219.899 276.563 150 276.563zm56.757-126.57l12.882-12.602c1.867-1.743 2.49-3.922 1.867-6.536-.747-2.551-2.365-4.139-4.854-4.76l-17.55-4.482 4.947-17.365c.747-2.552.156-4.73-1.773-6.535-1.805-1.93-3.983-2.52-6.535-1.774l-17.363 4.948-4.48-17.551c-.623-2.552-2.21-4.14-4.761-4.761-2.552-.685-4.73-.094-6.535 1.773L150 93.324l-12.602-12.977c-1.805-1.93-3.983-2.52-6.535-1.773-2.551.622-4.138 2.209-4.76 4.76l-4.481 17.552-17.363-4.948c-2.552-.747-4.73-.155-6.535 1.774-1.929 1.805-2.52 3.983-1.773 6.535l4.947 17.365-17.55 4.481c-2.489.623-4.107 2.21-4.854 4.761-.622 2.614 0 4.793 1.867 6.536l12.882 12.603-12.882 12.603c-1.867 1.743-2.49 3.921-1.867 6.535.747 2.552 2.365 4.14 4.854 4.762l17.55 4.481-4.947 17.365c-.747 2.552-.156 4.73 1.773 6.535 1.805 1.93 3.983 2.52 6.535 1.774l17.363-4.948 4.48 17.551c.623 2.552 2.21 4.17 4.761 4.855 2.614.622 4.792 0 6.535-1.867L150 206.755l12.602 12.884c1.245 1.369 2.832 2.053 4.761 2.053.436 0 1.027-.062 1.774-.186 2.551-.747 4.138-2.365 4.76-4.855l4.481-17.551 17.363 4.948c2.552.747 4.73.155 6.535-1.774 1.929-1.805 2.52-3.983 1.773-6.535l-4.947-17.365 17.55-4.481c2.489-.623 4.107-2.21 4.854-4.762.622-2.614 0-4.792-1.867-6.535l-12.882-12.603z"/></g></svg> \ No newline at end of file
diff --git a/addons/website_profile/static/src/img/rank_1.svg b/addons/website_profile/static/src/img/rank_1.svg
new file mode 100644
index 00000000000..6477a9068be
--- /dev/null
+++ b/addons/website_profile/static/src/img/rank_1.svg
@@ -0,0 +1 @@
+<svg height="37" viewBox="0 0 35 37" width="35" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><linearGradient id="a" x1="50%" x2="50%" y1="0%" y2="100%"><stop offset="0" stop-color="#ffe897"/><stop offset="1" stop-color="#f1c363"/></linearGradient><circle id="b" cx="17.5" cy="17.5" r="17.5"/><filter id="c" height="111.4%" width="105.7%" x="-2.9%" y="-2.9%"><feOffset dy="2" in="SourceAlpha" result="shadowOffsetOuter1"/><feColorMatrix in="shadowOffsetOuter1" values="0 0 0 0 0.564705882 0 0 0 0 0.37254902 0 0 0 0 0.192156863 0 0 0 1 0"/></filter></defs><g fill="none" fill-rule="evenodd"><g fill-rule="nonzero"><use fill="#000" filter="url(#c)" xlink:href="#b"/><use fill="url(#a)" xlink:href="#b"/></g><text fill="#905f31" font-family="Montserrat-Bold, Montserrat" font-size="11" font-weight="bold"><tspan x="10.031" y="22">1st</tspan></text></g></svg> \ No newline at end of file
diff --git a/addons/website_profile/static/src/img/rank_2.svg b/addons/website_profile/static/src/img/rank_2.svg
new file mode 100644
index 00000000000..e9ae886a34a
--- /dev/null
+++ b/addons/website_profile/static/src/img/rank_2.svg
@@ -0,0 +1 @@
+<svg height="37" viewBox="0 0 35 37" width="35" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><linearGradient id="a" x1="50%" x2="50%" y1="0%" y2="100%"><stop offset="0" stop-color="#d7dde1"/><stop offset="1" stop-color="#b7c3c8"/></linearGradient><circle id="b" cx="17.5" cy="17.5" r="17.5"/><filter id="c" height="111.4%" width="105.7%" x="-2.9%" y="-2.9%"><feOffset dy="2" in="SourceAlpha" result="shadowOffsetOuter1"/><feColorMatrix in="shadowOffsetOuter1" values="0 0 0 0 0.360784314 0 0 0 0 0.388235294 0 0 0 0 0.403921569 0 0 0 1 0"/></filter></defs><g fill="none" fill-rule="evenodd"><g fill-rule="nonzero"><use fill="#000" filter="url(#c)" xlink:href="#b"/><use fill="url(#a)" xlink:href="#b"/></g><text fill="#5c6367" font-family="Montserrat-Bold, Montserrat" font-size="11" font-weight="bold"><tspan x="6.615" y="22">2nd</tspan></text></g></svg> \ No newline at end of file
diff --git a/addons/website_profile/static/src/img/rank_3.svg b/addons/website_profile/static/src/img/rank_3.svg
new file mode 100644
index 00000000000..8c22af97f27
--- /dev/null
+++ b/addons/website_profile/static/src/img/rank_3.svg
@@ -0,0 +1 @@
+<svg height="37" viewBox="0 0 35 37" width="35" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><linearGradient id="a" x1="50%" x2="50%" y1="0%" y2="100%"><stop offset="0" stop-color="#ecc29f"/><stop offset="1" stop-color="#d09e7d"/></linearGradient><circle id="b" cx="17.5" cy="17.5" r="17.5"/><filter id="c" height="111.4%" width="105.7%" x="-2.9%" y="-2.9%"><feOffset dy="2" in="SourceAlpha" result="shadowOffsetOuter1"/><feColorMatrix in="shadowOffsetOuter1" values="0 0 0 0 0.62745098 0 0 0 0 0.462745098 0 0 0 0 0.329411765 0 0 0 1 0"/></filter></defs><g fill="none" fill-rule="evenodd"><g fill-rule="nonzero"><use fill="#000" filter="url(#c)" xlink:href="#b"/><use fill="url(#a)" xlink:href="#b"/></g><text fill="#5e4129" font-family="Montserrat-Bold, Montserrat" font-size="11" font-weight="bold"><tspan x="8.117" y="22">3rd</tspan></text></g></svg> \ No newline at end of file
diff --git a/addons/website_profile/static/src/scss/website_profile.scss b/addons/website_profile/static/src/scss/website_profile.scss
index e9fd045da45..224b46b1c84 100644
--- a/addons/website_profile/static/src/scss/website_profile.scss
+++ b/addons/website_profile/static/src/scss/website_profile.scss
@@ -1,326 +1,181 @@
-$gold: #ffcc00;
-$silver: #cccccc;
-$bronze: #eea91e;
-
-.o_wprofile_container {
- margin: auto;
- width: 100%;
- max-width: 1140px;
- padding: 32px 32px 0px 32px;
+$owprofile-color-bg: desaturate(map-get($grays, "200"), 80%);
+$owprofile-tabs-height: 37px;
+
+.o_wprofile_wrap {
+ background-color: $owprofile-color-bg;
}
-.o_wprofile_header {
- margin: auto;
- background-color: #875A7B !important;
- border-bottom: 48px solid #66445e;
- .o_wprofile_header_container {
- top: 48px;
- padding-top: 0px;
- .o_wprofile_id {
- font-size: 16px;
- .o_wprofile_id_info {
- margin-right: 36px;
- }
- }
- .o_wprofile_pict {
- .o_wprofile_photo {
- width: 100%;
- max-width: 256px;
- }
- }
- }
+.o_wprofile_gradient {
+ background-image: linear-gradient(120deg, #875A7B, darken(#875A7B, 10%));
}
-.o_wprofile_navtabs_container {
- .o_wprofile_nav_tabs {
- border-bottom: 0px;
- }
- .o_wprofile_navlink {
- color: white;
- border-radius: 0px;
- height: 48px;
- font-weight: bold;
- padding-top: 12px;
- border-left: 2px solid #875A7B;
- text-align: center;
- width: 120px;
- &:hover {
- border: 0px;
- border-radius: 0px;
- background-color: #875A7B;
- }
- }
- .o_wprofile_navlink.active {
- color: #212529;
- background-color: #f2f2f2;
- border: 0px;
+.o_wprofile_pict {
+ @include size(30%);
+ border-radius: 100%;
+ padding-top: 30%;
+ background-size: cover;
+ background-position: center;
+
+ @include media-breakpoint-up(md) {
+ @include size(100%);
+ border-radius: 0;
+ padding-top: 100%;
}
}
-.o_wprofile_content {
- margin: auto;
- background-color: #f2f2f2 !important;
- .o_wprofile_content_container{
- position: relative;
- top: -80px;
- .o_wprofile_detail {
- .o_wprofile_content_abstract {
- top: 1px;
- width: 256px;
- max-width: 256px;
- margin-left: 15px;
- table {
- width: 100%;
- tr {
- line-height: 32px;
- }
- th {
- font-weight: normal;
- }
- }
- .o_wprofile_rank {
- background-color: white;
- }
- .o_wprofile_abstract_info {
- background-color: white;
- }
- }
- .o_wprofile_content_detail {
- h4 {
- border-bottom: 1px solid lightgrey;
- color: grey;
- margin-bottom: 16px;
- }
- .o_user_badges {
- padding: 8px;
- div {
- padding: 8px;
- }
- img {
- width: 48px;
- height: 48px;
- }
- }
- }
+.o_wprofile_header {
+ @include media-breakpoint-up(md) {
+ &:before {
+ content: "";
+ @include o-position-absolute(auto, 0, 0, 0);
+ height: $owprofile-tabs-height;
+ background: rgba(black, 0.2);
}
}
}
-// Rank
-.o_wprofile_ranks_presentation {
- background-color: lightgray;
- .o_wprofile_rank_tile {
- width: 20%;
- div {
- text-align:center;
- img {
- height: 128px;
+.o_wprofile_nav_tabs {
+ @include media-breakpoint-up(md) {
+ margin-top: $owprofile-tabs-height * -1;
+ border-bottom: 0;
+
+ .nav-link {
+ border-radius: 0;
+ border-width: 0 1px;
+ @include o-hover-text-color(rgba(white, 0.8), white);
+
+ &, &:hover {
+ border-color: #875A7B;
+ background: transparent;
+ }
+
+ &.active {
+ color: $headings-color;
+ background: $owprofile-color-bg;
+ border-color: $owprofile-color-bg;
}
}
}
-}
-
-/// Progress Circle
-.o_wprofile_progress_circle {
- position: relative;
- $circle-size: 220px;
- $circle-background: rgba(black, 0.1);
- $circle-color: theme-color('primary');
- $inset-size: $circle-size - 20;
- $transition-length: 1s;
+ @include media-breakpoint-only(xs) {
+ overflow-x: auto;
+ overflow-y: hidden;
- .o_progress_circle_container, .o_pc, .o_pc div {
- @include size($circle-size);
+ li {
+ white-space: nowrap;
+ }
}
+}
- .o_pc_overlay, .o_pc, .o_pc div {
- border-radius: 50%;
+.o_wprofile_tabs_content {
+ @include media-breakpoint-down(sm) {
+ background-color: $nav-tabs-link-active-bg;
+ padding:0 ($grid-gutter-width * 0.5);
}
- .o_progress_circle_container {
- position: relative;
- margin: 0 auto;
- background-color: inherit;
+ @include media-breakpoint-only(xs) {
+ margin: 0 ($grid-gutter-width * -0.5);
}
+}
- .o_pc_overlay {
- $tmp-gap: ($circle-size - $inset-size)/2;
- @include o-position-absolute($tmp-gap, $left: $tmp-gap);
- @include size($inset-size);
-
- background-color: inherit;
- }
+/// Progress Circle
+.o_wprofile_progress_circle {
+ position: relative;
- .o_pc {
- background-color: $circle-background;
+ svg.o_pc_circular_chart {
+ display: block;
+ max-width: 100%;
- div {
- position: absolute;
- -webkit-backface-visibility: hidden;
- transition: transform $transition-length;
+ .o_pc_circle_bg, .o_pc_circle {
+ fill: none;
+ stroke-width: 1.5px;
+ stroke-linecap: round;
+ }
- &.o_pc_slice {
- clip: rect(0px, $circle-size, $circle-size, $circle-size/2);
+ .o_pc_circle_bg {
+ stroke: rgba(black, 0.1);
+ }
- .o_pc_fill {
- clip: rect(0px, $circle-size/2, $circle-size, 0px);
- background-color: $circle-color;
- }
- }
+ .o_pc_circle {
+ animation: progress 1s ease-out forwards;
}
- $i: 0;
- $increment: 180deg / 100;
- @while $i <= 100 {
- &[data-progress='#{$i}'] {
- .o_pc_slice.full, .o_pc_fill {
- transform: rotate($increment * $i);
- }
- .o_pc_fill.o_pc_bar {
- transform: rotate($increment * $i * 2);
- }
- $i: $i + 1;
- }
+ #gradient {
+ --o-pc-color-stop-1: lighten(theme-color('primary'), 10%);
+ --o-pc-color-stop-2: theme-color('primary');
}
}
-}
-
-.o_wprofile_next_rank_card {
- div {
- text-align: center;
- img {
- width: 128px;
- height: 128px;
- }
+ .o_pc_overlay {
+ @include o-position-absolute(0,0,0,0);
}
- .o_wprofile_next_rank_progress {
- background-color: lightgray;
- .o_wprofile_next_rank_progress_bar {
- background-color: #00A09D !important;
+
+ @keyframes progress {
+ 0% {
+ stroke-dasharray: 0 100;
}
}
}
-// Edit Profile button
-.o_wprofile_edit {
- position: absolute;
- top: 0px;
- right: 0px;
- padding-left: 16px;
- padding-right: 16px;
-}
-
// All Users Page
.o_wprofile_all_users_header {
- margin: auto;
- background-color: #875A7B !important;
- > div:first-child {
- margin: auto;
- width: 100%;
- max-width: 1140px;
- padding: 32px 32px 0px 32px;
- color: white;
- top: 48px;
- padding-top: 0px;
- height: 250px;
+ @include media-breakpoint-up(sm) {
+ min-height: 200px;
+ margin-bottom: -60px;
}
}
-.o_wprofile_all_user_content {
- position: relative;
- top: -140px;
- .o_wprofile_all_user_card_top3 {
- cursor: pointer;
- height: 325px;
- text-align: center;
- border-radius: 0px;
- border: 0px;
- .card-body {
- padding-left: 0px;
- padding-right: 0px;
- > div:first-child {
- margin-bottom: -40px;
- div {
- position: relative;
- margin: auto;
- right: -35px;
- bottom: 55px;
- width: 45px;
- padding: 12px 0px 12px 0px;
- border-radius: 50%;
- }
- .o_wprofile_top3_first {
- background-color: $gold;
- }
- .o_wprofile_top3_second {
- background-color: $silver;
- }
- .o_wprofile_top3_third {
- background-color: $bronze;
- }
+.o_wprofile_top3_card_footer div {
+ border-color: $border-color;
+ border-style: solid;
+ border-width: 1px 0;
+ margin-top: -1px;
+
+ + div {
+ border-left-width: 1px;
+ margin-left: -1px;
+ }
+}
+
+.o_wprofile_pager {
+ li.page-item {
+ a.page-link {
+ background-color: transparent;
+ border: 0;
+ color: $gray-600;
+ transition-duration: .3s;
+
+ &:hover {
+ color: $primary;
}
- :nth-child(4) {
- position: absolute;
- bottom: 0px;
- width: 100%;
- margin: auto;
- border-top: 1px solid lightgrey;
- div {
- padding: 4% 0% 4% 0%;
- border-radius: 0px;
- }
+ }
+
+ &.active {
+ a.page-link {
+ color: $white;
}
}
- }
- .o_wprofile_all_users_card {
- cursor: pointer;
- border-radius: 0px;
- border: 0px;
- padding: 8px 15px 8px 15px;
- > div > div:first-child > div > div:nth-child(3) {
- padding-top: 12px;
- padding-bottom: 12px;
+
+ &.o_wprofile_pager_arrow a {
+ color: $primary;
+
+ &:hover {
+ transform: scaleX(1.50) scaleY(1.50);
+ }
}
- > div > div:first-child > div > div:nth-child(2) {
- padding: 0px;
- width: 80px;
+
+ &.o_wprofile_pager_arrow.disabled a {
+ color: $gray-600;
}
- > div > div:first-child > div > div:first-child {
- padding: 5% 0px 5% 16px;
- width: 60px;
+
+ .page-link:focus {
+ box-shadow: 0 0 0 0;
}
}
}
-// Badges
-.badge-gold {
- color: $gold;
-}
-.bg-gold {
- background-color: $gold;
-}
-
-.badge-silver {
- color: $silver;
-}
-.bg-silver {
- background-color: $silver;
-}
-
-.badge-bronze {
- color: $bronze;
-}
-.bg-bronze {
- background-color: $bronze;
-}
// Other stuffs
-.o_wprofile_bb_grey {
- border-bottom: 2px solid lightgrey !important;
-}
-
.country_flag {
display: inline-block;
margin-left: 2px;
@@ -328,11 +183,7 @@ $bronze: #eea91e;
width: auto !important;
}
-.o_wprofile_img_small {
- width: 64px;
- height: 64px;
-}
-.o_wprofile_img_medium {
- width: 96px;
- height: 96px;
-}
+// Tools
+.o_wprofile_pointer {
+ cursor: pointer;
+} \ No newline at end of file
diff --git a/addons/website_profile/views/website_profile.xml b/addons/website_profile/views/website_profile.xml
index b7ab4704115..2a108ef8906 100644
--- a/addons/website_profile/views/website_profile.xml
+++ b/addons/website_profile/views/website_profile.xml
@@ -14,9 +14,10 @@
<!-- Edit Profile Page -->
<template id="user_profile_edit_main" name="Edit Profile">
<t t-call="website.layout">
- <div class="container mt16 mb32">
- <t t-call="website_profile.user_profile_edit_content">
- </t>
+ <div id="wrap" class="o_wprofile_wrap">
+ <div class="container pt-4 pb-5">
+ <t t-call="website_profile.user_profile_edit_content"/>
+ </div>
</div>
</t>
</template>
@@ -114,47 +115,46 @@
<!-- Profile Page -->
<template id="user_profile_main" name="Profile Page">
<t t-call="website.layout">
- <t t-call="website_profile.user_profile_header"></t>
- <t t-call="website_profile.user_profile_content"></t>
+ <div id="wrap" class="o_wprofile_wrap">
+ <t t-call="website_profile.user_profile_header"/>
+ <t t-call="website_profile.user_profile_content"/>
+ </div>
</t>
</template>
<template id="user_profile_header" name="Profile Page Header">
- <div class="row o_wprofile_header">
- <div class="col-lg-12 o_wprofile_container o_wprofile_header_container">
+ <div class="o_wprofile_header o_wprofile_gradient position-relative text-white pb-3 pb-md-0 pt-2 pt-md-5">
+ <div class="container">
<div class="row">
- <div class="col o_wprofile_pict">
- <img class="o_wprofile_photo mr-3" t-attf-src="/web/image/res.users/#{user.id}/image"/>
+ <!-- ==== Header Left ==== -->
+ <div class="col-12 col-md-4 col-lg-3">
+ <div t-attf-class="d-flex align-items-start #{'justify-content-between' if (request.env.user == user) else 'justify-content-around' }">
+ <div class="o_wprofile_pict d-inline-block mb-3 mb-md-0" t-attf-style="background-image:url(/web/image/res.users/#{user.id}/image)"/>
+ <a class="btn btn-primary d-inline-block d-md-none" t-if="request.env.user == user" t-attf-href="/profile/edit?url_param=#{edit_button_url_param}">
+ <i class="fa fa-pencil mr-1"/>EDIT
+ </a>
+ </div>
</div>
- <div class="col-8 o_wprofile_id">
- <a id="edit_profile_button" class="btn btn-primary o_wprofile_edit"
- t-if="request.env.user == user"
- t-attf-href="/profile/edit?url_param=#{edit_button_url_param}"><span class="font-weight-bold">EDIT PROFILE</span></a>
- <div class="row mb32">
- <h3 class="o_card_people_name text-white"><span class="font-weight-bold" t-field="user.name"/></h3>
+
+ <!-- ==== Header Right ==== -->
+ <div class="col-12 col-md-8 col-lg-9 d-flex flex-column">
+ <div class="d-flex justify-content-between align-items-start">
+ <h1 class="o_card_people_name" t-field="user.name"/>
+ <a class="btn btn-primary d-none d-md-inline-block" t-if="request.env.user == user" t-attf-href="/profile/edit?url_param=#{edit_button_url_param}">
+ <i class="fa fa-pencil mr-2"/>EDIT PROFILE
+ </a>
</div>
- <div class="row">
- <div class="o_wprofile_id_info">
- <t t-if="user.company_id">
- <span class="text-white">
- <i class="fa fa-building-o text-white mr-2"/><span t-field="user.company_id"/>
- </span>
- </t>
+
+ <div class="d-flex flex-column justify-content-center flex-grow-1 mb-0 mb-md-5">
+ <div t-if="user.company_id" class="lead mb-2">
+ <i class="fa fa-building-o fa-fw mr-1"/><span t-field="user.company_id"/>
</div>
- <div class="o_wprofile_id_info">
- <t t-if="user.city or user.country_id">
- <i class="fa fa-map-marker text-white mr-2"/>
- <span class="text-white">
- <span t-field="user.city"/><t t-if="user.country_id"> (</t><span t-field="user.country_id"/><t t-if="user.country_id">)</t>
- </span>
- </t>
+ <div t-if="user.city or user.country_id" class="lead mb-2">
+ <i class="fa fa-map-marker fa-fw mr-1"/>
+ <span t-field="user.city"/><span class="text-nowrap ml-1" t-if="user.country_id">(<span t-field="user.country_id"/>)</span>
</div>
- <div class="o_wprofile_id_info">
- <t t-if="user.website">
- <span class="text-white">
- <i class="fa fa-globe text-white mr-2"/><span t-field="user.website"/>
- </span>
- </t>
+ <div t-if="user.website" class="lead mb-2">
+ <i class="fa fa-globe fa-fw mr-1"/><span t-field="user.website"/>
</div>
</div>
</div>
@@ -164,85 +164,61 @@
</template>
<template id="user_profile_content" name="Profile Page Content">
- <div class="row o_wprofile_content">
- <div class="col-lg-12 o_wprofile_container o_wprofile_content_container">
- <div class="row">
- <div class="col o_wprofile_navtabs_blank"></div>
- <div class="col-8 o_wprofile_navtabs_container">
- <ul class="nav nav-tabs o_wprofile_nav_tabs" role="tablist" id="profile_extra_info_tablist">
- <li class="nav-item" id="profile_nav_tab_about">
- <a role="tab" aria-controls="about" href="#profile_tab_content_about" class="nav-link o_wprofile_navlink active" data-toggle="tab">About</a>
- </li>
- </ul>
- </div>
- </div>
- <div class="row o_wprofile_detail">
- <div class="col o_wprofile_content_abstract">
- <t t-if="user.rank_id">
- <div class="row">
- <div class="col-lg-12 pt16 o_wprofile_rank">
- <table><tbody><tr>
- <th><span class="font-weight-bold text-muted">Current <a href="/profile/ranks">Rank</a> : </span></th>
- <th><span class="float-right" t-field="user.rank_id"/></th>
- </tr></tbody></table>
- <t t-call="website_profile.profile_next_rank_card"></t>
- <hr/>
- </div>
+ <div class="o_wprofile_content container">
+ <div class="row">
+
+ <!-- ========== SIDEBAR ========== -->
+ <div class="col-12 col-md-4 col-lg-3 mt-3 mt-md-0">
+ <div class="bg-white px-3 py-2 py-md-3 mb-3 mb-md-5">
+ <div class="o_wprofile_sidebar_top d-flex justify-content-between">
+ <div t-if="user.rank_id" class="d-flex align-items-center">
+ <small class="font-weight-bold mr-2">Rank:</small>
+ <img t-attf-src="/web/image/gamification.karma.rank/#{user.rank_id.id}/image_small" height="16" alt="" class="mr-1"/>
+ <a href="/profile/ranks" t-field="user.rank_id"/>
</div>
- </t>
- <div class="row">
- <div class="col-lg-12 pt16 pb16 o_wprofile_abstract_info o_wprofile_bb_grey">
- <table>
- <tbody>
- <tr>
- <th style="width: 30%"><span class="font-weight-bold text-muted">Joined</span></th>
- <th style="width: 70%"><span t-field="user.create_date" t-options='{"format": "d MMM Y"}'/></th>
- </tr>
- <tr>
- <th style="width: 30%"><span class="font-weight-bold text-muted">Badges</span></th>
- <th style="width: 70%">
- <t t-if="user.badge_ids">
- <span t-esc="len(user.badge_ids)"/>
- </t>
- <t t-else="">
- <span>0</span>
- </t>
- </th>
- </tr>
- <t t-if="user.website">
- <tr>
- <th style="width: 30%"><span class="font-weight-bold text-muted">Website</span></th>
- <th style="width: 70%"><span t-field="user.website"/></th>
- </tr>
- </t>
- <t t-if="user.city or user.country_id">
- <tr>
- <th style="width: 30%"><span class="font-weight-bold text-muted">Location</span></th>
- <th style="width: 70%">
- <span t-field="user.city"/><t t-if="user.country_id"> (</t><span t-field="user.country_id"/><t t-if="user.country_id">)</t>
- </th>
- </tr>
- </t>
- <tr id="profile_abstract_info_company">
- <th style="width: 30%"><span class="font-weight-bold text-muted">Company</span></th>
- <th style="width: 70%"><span t-field="user.company_id"/></th>
- </tr>
- </tbody>
- </table>
+ <button class="btn btn-sm d-md-none bg-white border" type="button" data-toggle="collapse" data-target="#o_wprofile_sidebar_collapse" aria-expanded="false" aria-controls="o_wprofile_sidebar_collapse">More info</button>
+ </div>
+ <div class="collapse d-md-block" id="o_wprofile_sidebar_collapse">
+ <div t-if="user.next_rank_id" class="mb-3">
+ <small class="font-weight-bold mt-1">Next rank:</small>
+ <t t-call="website_profile.profile_next_rank_card">
+ <t t-set="img_max_width">40%</t>
+ </t>
</div>
+
+ <table class="table table-sm w-100" id="o_wprofile_sidebar_table">
+ <tbody>
+ <tr>
+ <th><small class="font-weight-bold">Joined</small></th>
+ <td><span t-field="user.create_date" t-options='{"format": "d MMM Y"}'/></td>
+ </tr>
+ <tr>
+ <th><small class="font-weight-bold">Badges</small></th>
+ <td t-if="user.badge_ids" t-esc="len(user.badge_ids)"/>
+ <td t-else="">0</td>
+ </tr>
+ </tbody>
+ </table>
</div>
</div>
- <div class="col-8 mt24 ml32 o_wprofile_content_detail">
- <div class="tab-content mt16" id="profile_extra_info_tabcontent">
- <div role="tabpanel" class="tab-pane active" id="profile_tab_content_about">
- <div id="profile_about_badge" class="mb32">
- <h4><i class="fa fa-certificate"/> Badges</h4>
- <t t-call="website_profile.user_badges"></t>
- </div>
- <div t-if="user.partner_id.website_description" class="mb32">
- <h4><i class="fa fa-pencil"/> Biography</h4>
- <span t-field="user.partner_id.website_description"/>
- </div>
+ </div>
+
+ <!-- ========== PROFILE CONTENT ========== -->
+ <div class="col-12 col-md-8 col-lg-9">
+ <ul class="nav nav-tabs o_wprofile_nav_tabs flex-nowrap" role="tablist" id="profile_extra_info_tablist">
+ <li class="nav-item">
+ <a role="tab" aria-controls="about" href="#profile_tab_content_about" class="nav-link active" data-toggle="tab">About</a>
+ </li>
+ </ul>
+ <div class="tab-content py-4 o_wprofile_tabs_content mb-4" id="profile_extra_info_tabcontent">
+ <div role="tabpanel" class="tab-pane active" id="profile_tab_content_about">
+ <div id="profile_about_badge" class="mb32">
+ <h5 class="border-bottom pb-1">Badges</h5>
+ <t t-call="website_profile.user_badges"></t>
+ </div>
+ <div t-if="user.partner_id.website_description" class="mb32">
+ <h5 class="border-bottom pb-1">Biography</h5>
+ <span t-field="user.partner_id.website_description"/>
</div>
</div>
</div>
@@ -252,58 +228,57 @@
</template>
<template id="profile_next_rank_card" name="Profile Next Rank Card">
- <div t-attf-class="o_wprofile_progress_circle #{bg_class if bg_class else ''}">
- <div class="o_progress_circle_container">
- <div class="o_pc" t-att-data-progress="int(100*(user.karma - user.rank_id.karma_min)/(user.next_rank_id.karma_min - user.rank_id.karma_min))" >
- <div class="o_pc_circle">
- <div class="o_pc_slice full">
- <div class="o_pc_fill"/>
- </div>
- <div class="o_pc_slice">
- <div class="o_pc_fill full"/>
- <div class="o_pc_fill o_pc_bar"/>
- </div>
- </div>
- </div>
- <div class="o_pc_overlay d-flex flex-column align-items-center justify-content-center">
- <img class="img-fluid"
- t-attf-src="/web/image/gamification.karma.rank/#{user.next_rank_id.id}/image_medium"
- t-att-alt="user.next_rank_id.name + ' badge'"
- t-att-style="'max-width: ' + (img_max_width if img_max_width else '60%;')"/>
- <h4 class="mt-2 mb-0">"<span t-field="user.next_rank_id.name"/>"</h4>
- <small>
- <span class="font-weight-bold text-primary" t-field="user.karma"/>/<span class="font-weight-bold" t-field="user.next_rank_id.karma_min"/> xp
- </small>
- </div>
+ <div t-if="user.next_rank_id" class="o_wprofile_progress_circle">
+ <svg viewBox="0 0 36 36" class="o_pc_circular_chart">
+ <t t-if="(user.next_rank_id.karma_min - user.rank_id.karma_min) > 0">
+ <t t-set="user_points" t-value="int(100*(user.karma - user.rank_id.karma_min)/(user.next_rank_id.karma_min - user.rank_id.karma_min))"/>
+ </t>
+ <t t-else="">
+ <t t-set="user_points" t-value="0"/>
+ </t>
+ <path class="o_pc_circle_bg" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
+ <path class="o_pc_circle" t-attf-stroke-dasharray="#{user_points}, 100" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" stroke="url(#gradient)" />
+ <linearGradient id="gradient">
+ <stop offset="0%" stop-color="var(--o-pc-color-stop-1)"/>
+ <stop offset="100%" stop-color="var(--o-pc-color-stop-2)"/>
+ </linearGradient>
+ </svg>
+ <div class="o_pc_overlay d-flex flex-column align-items-center justify-content-center">
+ <img class="img-fluid"
+ t-attf-src="/web/image/gamification.karma.rank/#{user.next_rank_id.id}/image_medium"
+ t-att-alt="user.next_rank_id.name + ' badge'"
+ t-att-style="'max-width: ' + (img_max_width if img_max_width else '50%;')"/>
+ <h4 class=" mb-0">"<span t-field="user.next_rank_id.name"/>"</h4>
+ <small>
+ <span class="font-weight-bold text-primary" t-field="user.karma"/>/<span class="font-weight-bold" t-field="user.next_rank_id.karma_min"/> xp
+ </small>
</div>
</div>
</template>
<template id="user_badges" name="User Bagdes">
- <t t-if="user.badge_ids">
- <div class="row">
- <div class="col-lg-3 o_user_badges" t-foreach="user.badge_ids" t-as="badge">
- <div class="card-body o_wprofile_bb_grey">
- <table><tbody><tr>
- <th>
- <img class="o_wprofile_img_thumb" t-attf-src="/web/image/gamification.badge/#{badge.badge_id.id}/image_small"/>
- </th>
- <th style="">
- <span class="font-weight-bold" t-field="badge.badge_id.name"/>
- </th>
- </tr></tbody></table>
+ <div t-if="user.badge_ids" class="row mx-n1">
+ <div class="col px-1 mb-2 col-xl-4" t-foreach="user.badge_ids" t-as="badge">
+ <t t-if="badge.badge_id.website_published">
+ <div class="card">
+ <div class="card-body p-2 pr-3">
+ <div class="media align-items-center">
+ <img height="38" t-attf-src="/web/image/gamification.badge/#{badge.badge_id.id}/image_small" class="mr-2"/>
+ <div class="media-body text-nowrap">
+ <h6 class="my-0" t-field="badge.badge_id.name"/>
+ </div>
+ </div>
+ </div>
</div>
- </div>
+ </t>
</div>
- </t>
- <div class="mb16" t-if="not user.badge_ids">
- <span class="font-weight-bold">No badge yet!</span><br/>
- <t t-if="badge_category">
- <a t-attf-href="/profile/badge?category=#{badge_category}" class="fa fa-arrow-right"> Check available badges</a>
- </t>
- <t t-else="">
- <a t-attf-href="/profile/badge" class="fa fa-arrow-right"> Check available badges</a>
- </t>
+ </div>
+ <div class="mb-3" t-if="not user.badge_ids">
+ <p class="text-muted">No badge yet</p>
+ </div>
+ <div class="text-right">
+ <a t-if="not user.badge_ids and badge_category" t-attf-href="/profile/badge?category=#{badge_category}"><i class="fa fa-arrow-right"/> Check available badges</a>
+ <a t-else="" href="/profile/badge" class="btn btn-link btn-sm"><i class="fa fa-arrow-right mr-1"/>Check all available badges</a>
</div>
</template>
@@ -311,36 +286,39 @@
<template id="rank_main" name="Ranks Page">
<t t-call="website.layout">
<div class="container mb32 mt48">
- <h1 class="o_page_header">
- Ranks &amp; Badges
- </h1>
- <p>
- Keep learning with <t t-esc="user.company_id.name"/>. Collect points on the forum or on the eLearning platform. Those points will make you reach new ranks.
- </p>
- <p>
- <span class="font-weight-bold">How do I earn badges?</span> When you finish a course or reach milestones, you're awarded badges.
- </p>
- <p>
- <span class="font-weight-bold">How do I score more points?</span> You can score more points by answering quizzes at the end of each course content. Points can also be earned on the forum. Follow this link to the guidelines of the forum.
- </p>
+ <div class="row justify-content-between">
+ <div class="col-12 col-md-6">
+ <h1>Ranks &amp; Badges</h1>
+ <p class="lead mb-4">Keep learning with <t t-esc="user.company_id.name"/>. Collect points on the forum or on the eLearning platform. Those points will make you reach new ranks.</p>
+ <h5>How do I earn badges?</h5>
+ <p>When you finish a course or reach milestones, you're awarded badges.</p>
+ <h5>How do I score more points?</h5>
+ <p>You can score more points by answering quizzes at the end of each course content. Points can also be earned on the forum. Follow this link to the guidelines of the forum.</p>
+ </div>
+ <div class="col-12 col-md-5 col-lg-4">
+ <t t-call="website_profile.rank_content"/>
+ </div>
+ </div>
</div>
- <t t-call="website_profile.rank_content">
- </t>
</t>
</template>
<template id="rank_content" name="Rank Page content">
- <div class="o_wprofile_ranks_presentation mt16 mb32 pt16 pb48">
- <div class="container mb32 mt48">
- <h3>Ranks</h3>
- <div class="row">
- <div class="o_wprofile_rank_tile" t-foreach="ranks" t-as="rank">
- <div><img class="mb-3" t-attf-src="/web/image/gamification.karma.rank/#{rank.id}/image"/></div>
- <div><span class="font-weight-bold" t-field="rank.name"/></div>
- <div><span style="color: #00A09D;"><span t-field="rank.karma_min"/> point<span t-if="rank.karma_min">s</span></span></div>
+ <h5 class="text-muted my-2">Ranks</h5>
+ <div class="row flex-column">
+ <t t-foreach="ranks" t-as="rank">
+ <t t-set="order_inverse" t-value="6 - rank.id"/>
+
+ <div t-attf-class="col order-#{order_inverse}">
+ <div class="media align-items-center py-2 border-top">
+ <img height="50" t-attf-src="/web/image/gamification.karma.rank/#{rank.id}/image" class="mr-2" alt="Rank badge"/>
+ <div class="media-body">
+ <h5 class="mt-0 mb-0" t-field="rank.name"/>
+ <span class="badge badge-success"><span t-field="rank.karma_min"/></span> point<span t-if="rank.karma_min">s</span>
+ </div>
</div>
</div>
- </div>
+ </t>
</div>
</template>
@@ -355,26 +333,23 @@
</template>
<template id="badge_content" name="Badges Page content">
- <h1 class="o_page_header">
- Badges
- </h1>
- <p>
+ <h1>Badges</h1>
+ <p class="lead">
Besides gaining reputation with your questions and answers,
- you receive badges for being especially helpful. Badges
+ you receive badges for being especially helpful.<br/>Badges
appear on your profile page, and your posts.
</p>
- <table class="table mt32 mb64">
+ <table class="table table-sm mt32 mb64">
<tr t-foreach="badges" t-as="badge">
- <td>
- <span class="badge badge-pill float-left">
- <span t-if="badge.level == 'gold'"><i class="fa fa-trophy badge-gold"/> <span t-field="badge.name" class="font-weight-bold text-white bg-gold badge-pill"/></span>
- <span t-if="badge.level == 'silver'"><i class="fa fa-trophy badge-silver"/> <span t-field="badge.name" class="font-weight-bold text-white bg-silver badge-pill"/></span>
- <span t-if="badge.level == 'bronze'"><i class="fa fa-trophy badge-bronze"/> <span t-field="badge.name" class="font-weight-bold text-white bg-bronze badge-pill"/></span>
- </span>
- </td><td>
- <span class="font-weight-bold" t-esc="badge.stat_count_distinct"/>
+ <td class="align-middle">
+ <img t-attf-src="/website_profile/static/src/img/badge_#{badge.level}.svg" class="my-1" style="height:2.5em" alt=""/>
+ <h6 t-field="badge.name" class="d-inline my-0"/>
+ </td>
+ <td class="align-middle d-none d-md-table-cell">
+ <b t-esc="badge.stat_count_distinct"/>
<i class="text-muted"> awarded users</i>
- </td><td>
+ </td>
+ <td class="align-middle">
<span t-field="badge.description"/>
</td>
</tr>
@@ -398,79 +373,94 @@
-->
<template id="users_page_main" name="Users Page">
<t t-call="website.layout">
- <t t-call="website_profile.users_page_header"></t>
- <t t-call="website_profile.users_page_content"></t>
+ <div id="wrap" class="o_wprofile_wrap pb-5">
+ <t t-call="website_profile.users_page_header"/>
+ <t t-call="website_profile.users_page_content"/>
+ </div>
</t>
</template>
<template id="users_page_header" name="Users Page Header">
- <div class="row o_wprofile_all_users_header">
- <div class="col-lg-12">
- <h2 class="row">Users</h2>
+ <div class="o_wprofile_all_users_header o_wprofile_gradient text-white">
+ <div class="container">
+ <h1 class="py-5">All Users</h1>
</div>
</div>
</template>
<template id="users_page_content">
- <div class="row o_wprofile_all_user_content">
- <div class="container mb32 mt48">
- <div class="row">
- <div class="col-lg-4 mb32" t-foreach="top3_users" t-as="user" t-attf-onclick="location.href='/profile/user/#{user['id']}';">
- <t t-call="website_profile.top3_user_card"></t>
- </div>
- </div>
- <div class="row">
- <div class="col-lg-12" t-foreach="users" t-as="user" t-attf-onclick="location.href='/profile/user/#{user['id']}';">
- <t t-call="website_profile.all_user_card"></t>
- </div>
- </div>
- <div class="float-left">
- <t t-call="website.pager"/>
+ <div class="container mb32">
+ <div class="row mb-3">
+ <div class="col-md-4" t-foreach="top3_users" t-as="user" t-attf-onclick="location.href='/profile/user/#{user['id']}';">
+ <t t-call="website_profile.top3_user_card"></t>
</div>
</div>
+ <table class="table table-sm">
+ <tr t-foreach="users" t-as="user" t-attf-onclick="location.href='/profile/user/#{user['id']}';" class="o_wprofile_pointer bg-white">
+ <t t-call="website_profile.all_user_card"/>
+ </tr>
+ </table>
+ <div class="form-inline justify-content-center">
+ <t t-call="website_profile.pager_nobox"/>
+ </div>
</div>
</template>
<template id="top3_user_card" name="Top 3 User Card">
- <div class="card o_wprofile_all_user_card_top3 o_wprofile_bb_grey">
+ <div class="card text-center mb-2 border-bottom-0 o_wprofile_pointer">
<div class="card-body">
- <div>
- <img class="mb-3 rounded-circle o_wprofile_img_medium" t-att-src="'/profile/avatar/%s?field=image_medium%s' % (user['id'], '&amp;res_model=%s&amp;res_id=%s' % (record._name, record.id) if record else '')"/>
- <div t-if="user['position'] == 1" class="o_wprofile_top3_first"><span>1st</span></div>
- <div t-if="user['position'] == 2" class="o_wprofile_top3_second"><span>2nd</span></div>
- <div t-if="user['position'] == 3" class="o_wprofile_top3_third"><span>3rd</span></div>
- </div>
- <div><h3 t-esc="user['name']"></h3></div>
- <div><span t-esc="user['rank']"></span></div>
- <div class="row">
- <div class="col" style="border-right: 1px solid lightgrey"><span><span t-esc="user['karma']"/> XP</span></div>
- <div id="top3_user_badge_count" class="col"><span><span t-esc="user['badge_count']"/> Badges</span></div>
+ <div class="d-inline-block position-relative">
+ <img class="rounded-circle img-fluid" t-att-src="'/profile/avatar/%s?field=image_medium%s' % (user['id'], '&amp;res_model=%s&amp;res_id=%s' % (record._name, record.id) if record else '')"/>
+ <img class="position-absolute" t-attf-src="/website_profile/static/src/img/rank_#{user['position']}.svg" alt="User rank" style="bottom: 0; right: -10px"/>
</div>
+ <h3 class="mt-2 mb-0" t-esc="user['name']"></h3>
+ <strong class="text-muted" t-esc="user['rank']"/>
+ </div>
+ <div class="row mx-0 o_wprofile_top3_card_footer text-nowrap">
+ <div class="col py-3"><b t-esc="user['karma']"/> <span class="text-muted">XP</span></div>
+ <div class="col py-3"><b t-esc="user['badge_count']"/> <span class="text-muted">Badges</span></div>
</div>
</div>
</template>
<template id="all_user_card" name="All User Card">
- <div class="card o_wprofile_all_users_card o_wprofile_bb_grey">
- <div class="row">
- <div class="col-lg-4">
- <div class="row">
- <div><span><span t-esc="user['position']"/>th</span></div>
- <div><img class="rounded-circle o_wprofile_img_small" t-att-src="'/profile/avatar/%s?field=image_medium%s' % (user['id'], '&amp;res_model=%s&amp;res_id=%s' % (record._name, record.id) if record else '')"/></div>
- <div>
- <span class="font-weight-bold" t-esc="user['name']"/><br/>
- <span t-esc="user['rank']"></span>
- </div>
- </div>
- </div>
- <div class="card-body col-lg-8">
- <div class="row">
- <div class="col"><span><span t-esc="user['karma']"/> XP</span></div>
- <div id="all_user_badge_count" class="col"><span><span t-esc="user['badge_count']"/> Badges</span></div>
- </div>
- </div>
- </div>
- </div>
+ <td class="align-middle text-right text-muted" style="width: 0">
+ <span t-esc="user['position']"/>
+ </td>
+ <td class="align-middle d-none d-sm-table-cell">
+ <img class="rounded-circle o_wprofile_img_small" height="30" t-att-src="'/profile/avatar/%s?field=image_medium%s' % (user['id'], '&amp;res_model=%s&amp;res_id=%s' % (record._name, record.id) if record else '')"/>
+ </td>
+ <td class="align-middle w-md-75">
+ <span class="font-weight-bold" t-esc="user['name']"/><br/>
+ <span class="text-muted font-weight-bold" t-esc="user['rank']"></span>
+ </td>
+ <td class="align-middle font-weight-bold text-right text-nowrap">
+ <b t-esc="user['karma']"/> <span class="text-muted small font-weight-bold">XP</span>
+ </td>
+ <td class="align-middle font-weight-bold text-right pr-3 text-nowrap all_user_badge_count">
+ <b t-esc="user['badge_count']"/> <span class="text-muted small font-weight-bold">Badges</span>
+ </td>
+ </template>
+
+ <!-- Custom Pager: lighter than existing one, no box around number, first / end displayed -->
+ <template id="pager_nobox" name="Pager (not box display)">
+ <ul t-if="pager['page_count'] > 1" t-attf-class="o_wprofile_pager font-weight-bold pagination m-0">
+ <li t-attf-class="page-item o_wprofile_pager_arrow #{'disabled' if pager['page']['num'] == 1 else ''}">
+ <a t-att-href=" pager['page_first']['url'] if pager['page']['num'] != 1 else None" class="page-link"><i class="fa fa-step-backward"/></a>
+ </li>
+ <li t-attf-class="page-item o_wprofile_pager_arrow #{'disabled' if pager['page']['num'] == 1 else ''}">
+ <a t-att-href=" pager['page_previous']['url'] if pager['page']['num'] != 1 else None" class="page-link"><i class="fa fa-caret-left"/></a>
+ </li>
+ <t t-foreach="pager['pages']" t-as="page">
+ <li t-attf-class="page-item #{'active disabled bg-primary rounded-circle' if page['num'] == pager['page']['num'] else ''}"> <a t-att-href="page['url']" class="page-link" t-raw="page['num']"></a></li>
+ </t>
+ <li t-attf-class="page-item o_wprofile_pager_arrow #{'disabled' if pager['page']['num'] == pager['page_count'] else ''}">
+ <a t-att-href="pager['page_next']['url'] if pager['page']['num'] != pager['page_count'] else None" class="page-link"><i class="fa fa-caret-right"/></a>
+ </li>
+ <li t-attf-class="page-item o_wprofile_pager_arrow #{'disabled' if pager['page']['num'] == pager['page_count'] else ''}">
+ <a t-att-href=" pager['page_last']['url'] if pager['page']['num'] != pager['page_count'] else None" class="page-link"><i class="fa fa-step-forward"/></a>
+ </li>
+ </ul>
</template>
</data></odoo>
diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py
index 5e4956a7319..d22b69b8636 100644
--- a/addons/website_sale/controllers/main.py
+++ b/addons/website_sale/controllers/main.py
@@ -230,7 +230,7 @@ class WebsiteSale(ProductConfiguratorController):
if attrib_list:
post['attrib'] = attrib_list
- Product = request.env['product.template']
+ Product = request.env['product.template'].with_context(bin_size=True)
Category = request.env['product.public.category']
search_categories = False
diff --git a/addons/website_sale/models/product.py b/addons/website_sale/models/product.py
index 5670bbcb561..c1086108f73 100644
--- a/addons/website_sale/models/product.py
+++ b/addons/website_sale/models/product.py
@@ -138,7 +138,7 @@ class ProductTemplate(models.Model):
def _website_price(self):
current_website = self.env['website'].get_current_website()
for template in self.with_context(website_id=current_website.id):
- res = template._get_combination_info(template._get_first_possible_combination())
+ res = template._get_combination_info()
template.website_price = res.get('price')
template.website_public_price = res.get('list_price')
template.website_price_difference = res.get('has_discounted_price')
@@ -168,9 +168,12 @@ class ProductTemplate(models.Model):
@api.multi
def _is_quick_add_to_cart_possible(self, parent_combination=None):
"""
- It's possible to quickly add to cart if there's no optional product
- and there's only one possible combination, and no attribute is set
- to dynamic or no_variant, and no value is set to is_custom.
+ It's possible to quickly add to cart if there's no optional product,
+ there's only one possible combination and no value is set to is_custom.
+
+ Attributes set to dynamic or no_variant don't have to be tested
+ specifically because they will be taken into account when checking for
+ the possible combinations.
:param parent_combination: combination from which `self` is an
optional or accessory product
@@ -183,15 +186,14 @@ class ProductTemplate(models.Model):
if not self._is_add_to_cart_possible(parent_combination):
return False
- if len(self._get_possible_variants(parent_combination)) != 1:
- return False
- if self._has_no_variant_attributes():
- return False
- if self.has_dynamic_attributes():
+ gen = self._get_possible_combinations(parent_combination)
+ first_possible_combination = next(gen)
+ if next(gen, False) is not False:
+ # there are at least 2 possible combinations.
return False
if self._has_is_custom_values():
return False
- if self.optional_product_ids.filtered(lambda p: p._is_add_to_cart_possible(self._get_first_possible_combination())):
+ if self.optional_product_ids.filtered(lambda p: p._is_add_to_cart_possible(first_possible_combination)):
return False
return True
@@ -202,7 +204,8 @@ class ProductTemplate(models.Model):
The order is based on the order of the attributes and their values.
See `_get_possible_variants` for the limitations of this method with
- dynamic or no_variant attributes.
+ dynamic or no_variant attributes, and also for a warning about
+ performances.
:param parent_combination: combination from which `self` is an
optional or accessory product
@@ -233,7 +236,7 @@ class ProductTemplate(models.Model):
return self._get_possible_variants(parent_combination).sorted(_sort_key_variant)
@api.multi
- def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, parent_combination=False):
+ def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, parent_combination=False, only_template=False):
"""Override for website, where we want to:
- take the website pricelist if no pricelist is set
- apply the b2b/b2c setting to the result
@@ -250,7 +253,9 @@ class ProductTemplate(models.Model):
if not pricelist:
pricelist = current_website.get_current_pricelist()
- combination_info = super(ProductTemplate, self)._get_combination_info(combination, product_id, add_qty, pricelist, parent_combination)
+ combination_info = super(ProductTemplate, self)._get_combination_info(
+ combination=combination, product_id=product_id, add_qty=add_qty, pricelist=pricelist,
+ parent_combination=parent_combination, only_template=only_template)
if self.env.context.get('website_id'):
partner = self.env.user.partner_id
diff --git a/addons/website_sale/models/website.py b/addons/website_sale/models/website.py
index e848067f1b2..11bdba05079 100644
--- a/addons/website_sale/models/website.py
+++ b/addons/website_sale/models/website.py
@@ -246,7 +246,7 @@ class Website(models.Model):
country_code = request.session['geoip'].get('country_code')
if country_code:
country_id = request.env['res.country'].search([('code', '=', country_code)], limit=1).id
- fp_id = request.env['account.fiscal.position'].sudo()._get_fpos_by_region(country_id)
+ fp_id = request.env['account.fiscal.position'].sudo().with_context(force_company=request.website.company_id.id)._get_fpos_by_region(country_id)
sale_order.fiscal_position_id = fp_id
else:
# if no geolocation, use the public user fp
diff --git a/addons/website_sale/static/src/js/website_sale.js b/addons/website_sale/static/src/js/website_sale.js
index 09b4677fe61..9cab8e8106e 100644
--- a/addons/website_sale/static/src/js/website_sale.js
+++ b/addons/website_sale/static/src/js/website_sale.js
@@ -144,6 +144,8 @@ publicWidget.registry.WebsiteSale = publicWidget.Widget.extend(ProductConfigurat
'change #shipping_use_same': '_onChangeShippingUseSame',
'click .toggle_summary': '_onToggleSummary',
'click input.js_product_change': 'onChangeVariant',
+ // dirty fix: prevent options modal events to be triggered and bubbled
+ 'change oe_optional_products_modal [data-attribute_exclusions]': 'onChangeVariant',
}),
/**
@@ -588,6 +590,20 @@ publicWidget.registry.WebsiteSale = publicWidget.Widget.extend(ProductConfigurat
$('.ship_to_other').toggle(!$(ev.currentTarget).prop('checked'));
},
/**
+ * @override
+ *
+ * Dirty fix: prevent options modal events to be triggered and bubbled
+ */
+ onChangeVariant: function (ev, data) {
+ var $originPath = ev.originalEvent && Array.isArray(ev.originalEvent.path) ? $(ev.originalEvent.path) : $();
+ var $container = data && data.$container ? data.$container : $();
+ if ($originPath.add($container).hasClass('oe_optional_products_modal')) {
+ ev.stopPropagation();
+ return;
+ }
+ return ProductConfiguratorMixin.onChangeVariant.apply(this, arguments);
+ },
+ /**
* @private
*/
_onToggleSummary: function () {
diff --git a/addons/website_sale/static/src/js/website_sale_tour_shop_custom_attribute_value.js b/addons/website_sale/static/src/js/website_sale_tour_shop_custom_attribute_value.js
index 48d1eb81343..bc651d2801f 100644
--- a/addons/website_sale/static/src/js/website_sale_tour_shop_custom_attribute_value.js
+++ b/addons/website_sale/static/src/js/website_sale_tour_shop_custom_attribute_value.js
@@ -3,6 +3,8 @@ odoo.define("website_sale.tour_shop_custom_attribute_value", function (require)
var tour = require("web_tour.tour");
+ var optionVariantImage;
+
tour.register("shop_custom_attribute_value", {
url: "/shop?search=Customizable Desk",
test: true,
@@ -33,6 +35,24 @@ odoo.define("website_sale.tour_shop_custom_attribute_value", function (require)
trigger: 'span.oe_price span:contains(600)',
run: function (){}, // check
}, {
+ trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("Conference Chair (Steel)")',
+ run: function () {
+ optionVariantImage = $('.oe_optional_products_modal .js_product:eq(1) img.variant_image').attr('src');
+ }
+ }, {
+ trigger: '.oe_optional_products_modal .js_product:eq(1) input[data-value_name="Aluminium"]',
+ }, {
+ trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("Conference Chair (Aluminium)")',
+ run: function () {
+ var newVariantImage = $('.oe_optional_products_modal .js_product:eq(1) img.variant_image').attr('src');
+ if (newVariantImage !== optionVariantImage) {
+ $('<p>').text('image variant option src changed').insertAfter('.oe_optional_products_modal .js_product:eq(1) .product-name');
+ }
+ }
+ }, {
+ extra_trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("image variant option src changed")',
+ trigger: '.oe_optional_products_modal .js_product:eq(1) input[data-value_name="Steel"]',
+ }, {
trigger: 'li.js_attribute_value span:contains(Aluminium)',
extra_trigger: '.oe_optional_products_modal',
run: 'click'
diff --git a/addons/website_sale/static/src/js/website_sale_tour_shop_customize.js b/addons/website_sale/static/src/js/website_sale_tour_shop_customize.js
index b7a6a32a1c4..8aa91ddc115 100644
--- a/addons/website_sale/static/src/js/website_sale_tour_shop_customize.js
+++ b/addons/website_sale/static/src/js/website_sale_tour_shop_customize.js
@@ -68,6 +68,11 @@ odoo.define('website_sale.tour_shop_customize', function (require) {
run: function () {}, // it's a check
},
{
+ content: "check list view of variants is disabled initially",
+ trigger: 'body:not(:has(.js_product_change))',
+ run: function () {},
+ },
+ {
content: "click on 'List View of Variants'",
trigger: "#customize-menu a:contains(List View of Variants)",
},
@@ -77,6 +82,11 @@ odoo.define('website_sale.tour_shop_customize', function (require) {
run: function () {}, // it's a check
},
{
+ context: "check variant price",
+ trigger: '.custom-radio:contains("Aluminium") .badge:contains("+") .oe_currency_value:contains("50.4")',
+ run: function () {},
+ },
+ {
content: "check price is 750",
trigger: ".product_price .oe_price .oe_currency_value:containsExact(750.00)",
run: function () {},
diff --git a/addons/website_sale/static/src/js/website_sale_tour_shop_deleted_archived_variants.js b/addons/website_sale/static/src/js/website_sale_tour_shop_deleted_archived_variants.js
index 5d485b20c13..36adf9a9f88 100644
--- a/addons/website_sale/static/src/js/website_sale_tour_shop_deleted_archived_variants.js
+++ b/addons/website_sale/static/src/js/website_sale_tour_shop_deleted_archived_variants.js
@@ -6,12 +6,16 @@ var tour = require('web_tour.tour');
// This tour relies on a data created from the python test.
tour.register('tour_shop_deleted_archived_variants', {
test: true,
- url: '/shop?search=Test Product',
+ url: '/shop?search=Test Product 2',
},
[
{
- content: "select Test Product",
- trigger: '.oe_product_cart a:containsExact("Test Product")',
+ content: "check price on /shop (template price)",
+ trigger: '.oe_product_cart .oe_currency_value:contains("1.00")',
+ },
+ {
+ content: "select Test Product 2",
+ trigger: '.oe_product_cart a:containsExact("Test Product 2")',
},
{
content: "check price (3rd variant)",
diff --git a/addons/website_sale/static/src/js/website_sale_tour_shop_dynamic_variants.js b/addons/website_sale/static/src/js/website_sale_tour_shop_dynamic_variants.js
index 3afcad271f7..7ae321912c4 100644
--- a/addons/website_sale/static/src/js/website_sale_tour_shop_dynamic_variants.js
+++ b/addons/website_sale/static/src/js/website_sale_tour_shop_dynamic_variants.js
@@ -24,6 +24,7 @@ tour.register('tour_shop_dynamic_variants', {
},
{
content: "click add to cart",
+ extra_trigger: 'body:has(input[type="hidden"][name="product_id"][value=0])',
trigger: '#add_to_cart',
},
{
diff --git a/addons/website_sale/static/src/js/website_sale_tour_shop_list_view_b2c.js b/addons/website_sale/static/src/js/website_sale_tour_shop_list_view_b2c.js
new file mode 100644
index 00000000000..94178547a8b
--- /dev/null
+++ b/addons/website_sale/static/src/js/website_sale_tour_shop_list_view_b2c.js
@@ -0,0 +1,96 @@
+odoo.define('website_sale.tour_shop_list_view_b2c', function (require) {
+'use strict';
+
+var rpc = require('web.rpc');
+var tour = require('web_tour.tour');
+
+tour.register('shop_list_view_b2c', {
+ test: true,
+ url: '/shop?search=Test Product',
+},
+ [
+ {
+ content: "activate b2c",
+ trigger: 'body',
+ run: function () {
+ return rpc.query({
+ model: 'res.config.settings',
+ method: 'create',
+ args: [{
+ 'auth_signup_uninvited': 'b2c',
+ 'show_line_subtotals_tax_selection': 'tax_included',
+ 'group_show_line_subtotals_tax_excluded': false,
+ 'group_show_line_subtotals_tax_included': true,
+ }],
+ }).then(function (resId) {
+ return rpc.query({
+ model: 'res.config.settings',
+ method: 'execute',
+ args: [[resId]],
+ }).then(function () {
+ window.location.reload();
+ });
+ });
+ },
+ },
+ {
+ content: "check price on /shop",
+ trigger: '.oe_product_cart .oe_currency_value:contains("825.00")',
+ run: function () {},
+ },
+ {
+ content: "select product",
+ trigger: '.oe_product_cart a:contains("Test Product")',
+ },
+ {
+ content: "check list view of variants is disabled initially (when on /product page)",
+ trigger: 'body:not(:has(.js_product_change))',
+ extra_trigger: '#product_details',
+ run: function () {},
+ },
+ {
+ content: "open customize menu",
+ trigger: '#customize-menu > a',
+ extra_trigger: 'body:not(.notReady)',
+ },
+ {
+ content: "click on 'List View of Variants'",
+ trigger: '#customize-menu a:contains(List View of Variants)',
+ },
+ {
+ content: "check page loaded after list of variant customization enabled",
+ trigger: '.js_product_change',
+ run: function () {},
+ },
+ {
+ context: "check variant price",
+ trigger: '.custom-radio:contains("Aluminium") .badge:contains("+") .oe_currency_value:contains("55.44")',
+ run: function () {},
+ },
+ {
+ content: "check price is 825",
+ trigger: '.product_price .oe_price .oe_currency_value:containsExact("825.00")',
+ run: function () {},
+ },
+ {
+ content: "switch to another variant",
+ trigger: '.js_product label:contains("Aluminium")',
+ },
+ {
+ content: "verify that price has changed when changing variant",
+ trigger: '.product_price .oe_price .oe_currency_value:containsExact("880.44")',
+ run: function () {},
+ },
+ {
+ content: "click on 'Add to Cart' button",
+ trigger: 'a:contains(Add to Cart)',
+ },
+ {
+ content: "check price on /cart",
+ trigger: '#cart_products .oe_currency_value:containsExact("880.44")',
+ run: function () {},
+ },
+ ]
+);
+
+});
diff --git a/addons/website_sale/static/src/js/website_sale_tour_shop_no_variant_attribute.js b/addons/website_sale/static/src/js/website_sale_tour_shop_no_variant_attribute.js
index 012072d178b..169819fe997 100644
--- a/addons/website_sale/static/src/js/website_sale_tour_shop_no_variant_attribute.js
+++ b/addons/website_sale/static/src/js/website_sale_tour_shop_no_variant_attribute.js
@@ -6,12 +6,12 @@ var tour = require('web_tour.tour');
// This tour relies on a data created from the python test.
tour.register('tour_shop_no_variant_attribute', {
test: true,
- url: '/shop?search=Test Product',
+ url: '/shop?search=Test Product 3',
},
[
{
- content: "select Test Product",
- trigger: '.oe_product_cart a:containsExact("Test Product")',
+ content: "select Test Product 3",
+ trigger: '.oe_product_cart a:containsExact("Test Product 3")',
},
{
content: "check price",
diff --git a/addons/website_sale/tests/test_customize.py b/addons/website_sale/tests/test_customize.py
index 11f10c4c5e0..944d6f33a93 100644
--- a/addons/website_sale/tests/test_customize.py
+++ b/addons/website_sale/tests/test_customize.py
@@ -6,13 +6,18 @@ import odoo.tests
@odoo.tests.common.tagged('post_install', '-at_install')
class TestUi(odoo.tests.HttpCase):
- def test_01_admin_shop_customize_tour(self):
+ def setUp(self):
+ super(TestUi, self).setUp()
# create a template
product_template = self.env['product.template'].create({
'name': 'Test Product',
'website_published': True,
'list_price': 750,
})
+
+ tax = self.env['account.tax'].create({'name': "Test tax", 'amount': 10})
+ product_template.taxes_id = tax
+
product_attribute = self.env.ref('product.product_attribute_1')
product_attribute_value_1 = self.env.ref('product.product_attribute_value_1')
product_attribute_value_2 = self.env.ref('product.product_attribute_value_2')
@@ -36,6 +41,7 @@ class TestUi(odoo.tests.HttpCase):
product_template.create_variant_ids()
+ def test_01_admin_shop_customize_tour(self):
self.phantom_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('shop_customize')", "odoo.__DEBUG__.services['web_tour.tour'].tours.shop_customize.ready", login="admin")
def test_02_admin_shop_custom_attribute_value_tour(self):
@@ -140,7 +146,7 @@ class TestUi(odoo.tests.HttpCase):
# create the template
product_template = self.env['product.template'].create({
- 'name': 'Test Product',
+ 'name': 'Test Product 2',
'website_published': True,
})
@@ -161,9 +167,10 @@ class TestUi(odoo.tests.HttpCase):
product_template.create_variant_ids()
- # archive first and delete second
+ # archive first combination (first variant)
product_template.product_variant_ids[0].active = False
- product_template.product_variant_ids[1].unlink()
+ # delete second combination (which is now first variant since cache has been cleared)
+ product_template.product_variant_ids[0].unlink()
self.phantom_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('tour_shop_deleted_archived_variants')", "odoo.__DEBUG__.services['web_tour.tour'].tours.tour_shop_deleted_archived_variants.ready", login="portal")
@@ -188,7 +195,7 @@ class TestUi(odoo.tests.HttpCase):
# create the template
product_template = self.env['product.template'].create({
- 'name': 'Test Product',
+ 'name': 'Test Product 3',
'website_published': True,
})
@@ -205,3 +212,6 @@ class TestUi(odoo.tests.HttpCase):
product_template.create_variant_ids()
self.phantom_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('tour_shop_no_variant_attribute')", "odoo.__DEBUG__.services['web_tour.tour'].tours.tour_shop_no_variant_attribute.ready", login="demo")
+
+ def test_06_admin_list_view_b2c(self):
+ self.browser_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('shop_list_view_b2c')", "odoo.__DEBUG__.services['web_tour.tour'].tours.shop_list_view_b2c.ready", login="admin")
diff --git a/addons/website_sale/tests/test_website_sale_pricelist.py b/addons/website_sale/tests/test_website_sale_pricelist.py
index 390b96e02d1..a9a5c4981b5 100644
--- a/addons/website_sale/tests/test_website_sale_pricelist.py
+++ b/addons/website_sale/tests/test_website_sale_pricelist.py
@@ -38,6 +38,10 @@ class TestWebsitePriceList(TransactionCase):
'country_group_ids': [(6, 0, [ca_group.id])],
'sequence': 10
})
+ self.args = {
+ 'show': False,
+ 'current_pl': False,
+ }
patcher = patch('odoo.addons.website_sale.models.website.Website.get_pricelist_available', wraps=self._get_pricelist_available)
patcher.start()
self.addCleanup(patcher.stop)
@@ -87,11 +91,6 @@ class TestWebsitePriceList(TransactionCase):
def test_get_pricelist_available_promocode(self):
christmas_pl = self.ref('website_sale.list_christmas')
- public_pl = self.ref('product.list0')
- self.args = {
- 'show': False,
- 'current_pl': public_pl,
- }
country_list = {
False: True,
diff --git a/addons/website_sale/tests/test_website_sale_product_attribute_value_config.py b/addons/website_sale/tests/test_website_sale_product_attribute_value_config.py
index 1c08075178f..6bd1f72ddc7 100644
--- a/addons/website_sale/tests/test_website_sale_product_attribute_value_config.py
+++ b/addons/website_sale/tests/test_website_sale_product_attribute_value_config.py
@@ -32,7 +32,6 @@ class TestWebsiteSaleProductAttributeValueConfig(TestSaleProductAttributeValueSe
# ensure pricelist is set to with_discount
pricelist.discount_policy = 'with_discount'
- self.computer.invalidate_cache()
# CASE: B2B setting
self.env.ref('account.group_show_line_subtotals_tax_excluded').users |= self.env.user
@@ -54,7 +53,6 @@ class TestWebsiteSaleProductAttributeValueConfig(TestSaleProductAttributeValueSe
# CASE: pricelist 'without_discount'
pricelist.discount_policy = 'without_discount'
- self.computer.invalidate_cache()
# ideally we would need to use compare_amounts everywhere, but this is
# the only rounding where it fails without it
diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml
index 557b788e558..8c643c918e2 100644
--- a/addons/website_sale/views/templates.xml
+++ b/addons/website_sale/views/templates.xml
@@ -33,6 +33,7 @@
<script type="text/javascript" src="/website_sale/static/src/js/website_sale_tour_shop_zoom.js"></script>
<script type="text/javascript" src="/website_sale/static/src/js/website_sale_tour_shop_dynamic_variants.js"></script>
<script type="text/javascript" src="/website_sale/static/src/js/website_sale_tour_shop_deleted_archived_variants.js"></script>
+ <script type="text/javascript" src="/website_sale/static/src/js/website_sale_tour_shop_list_view_b2c.js"></script>
<script type="text/javascript" src="/website_sale/static/src/js/website_sale_tour_shop_no_variant_attribute.js"></script>
<script type="text/javascript" src="/website_sale/static/src/js/website_sale_options.js"></script>
</xpath>
@@ -103,8 +104,7 @@
<template id="products_item" name="Products item">
<t t-set="product_href" t-value="request.httprequest.url_root + keep('shop/product/%s' % slug(product), page=(pager['page']['num'] if pager['page']['num']&gt;1 else None))" />
- <t t-set="first_possible_combination" t-value="product._get_first_possible_combination()"/>
- <t t-set="combination_info" t-value="product._get_combination_info(first_possible_combination, add_qty=add_qty or 1, pricelist=pricelist)"/>
+ <t t-set="combination_info" t-value="product._get_combination_info(only_template=True, add_qty=add_qty or 1, pricelist=pricelist)"/>
<form action="/shop/cart/update" method="post">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()" />
@@ -136,7 +136,7 @@
<t t-if="True"><!-- compatibility 12.0 -->
<del t-attf-class="text-danger mr8 {{'' if combination_info['has_discounted_price'] else 'd-none'}}" style="white-space: nowrap;" t-esc="combination_info['list_price']" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}" />
</t>
- <span t-esc="combination_info['price']" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
+ <span t-if="combination_info['price']" t-esc="combination_info['price']" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
<span itemprop="price" style="display:none;" t-esc="combination_info['price']" />
<span itemprop="priceCurrency" style="display:none;" t-esc="website.currency_id.name" />
</b>
@@ -156,18 +156,13 @@
<template id="products_add_to_cart" inherit_id="website_sale.products_item" active="False" customize_show="True" name="Add to Cart">
<xpath expr="//div[hasclass('product_price')]" position="inside">
- <t t-set="product_variant" t-value="product_variant or product._create_first_product_variant()"/>
- <input name="product_id" t-att-value="product_variant.id" type="hidden" />
- <t t-if="product._is_quick_add_to_cart_possible()">
+ <t t-set="product_variant_id" t-value="product._get_first_possible_variant_id()"/>
+ <input name="product_id" t-att-value="product_variant_id" type="hidden"/>
+ <t t-if="product_variant_id">
<a href="#" role="button" class="btn btn-secondary btn-sm a-submit" aria-label="Shopping cart" title="Shopping cart">
<span class="fa fa-shopping-cart" />
</a>
</t>
- <t t-elif="product._is_add_to_cart_possible()">
- <a role="button" class="btn btn-secondary btn-sm" t-att-href="keep('/shop/product/%s' % slug(product), page=(pager['page']['num'] if pager['page']['num']&gt;1 else None))" aria-label="Shopping cart" title="Shopping cart">
- <span class="fa fa-shopping-cart" />
- </a>
- </t>
</xpath>
</template>
@@ -629,12 +624,14 @@
<input type="hidden" t-if="len(filtered_sorted_variants) == 1" class="product_id" name="product_id" t-att-value="filtered_sorted_variants[0].id"/>
<t t-if="len(filtered_sorted_variants) &gt; 1">
<div t-foreach="filtered_sorted_variants" t-as="variant_id" class="custom-control custom-radio">
- <input type="radio" name="product_id" class="custom-control-input product_id 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" t-att-data-combination="variant_id.product_template_attribute_value_ids.ids"/>
+ <t t-set="template_combination_info" t-value="product._get_combination_info(only_template=True, add_qty=add_qty, pricelist=pricelist)"/>
+ <t t-set="combination_info" t-value="variant_id._get_combination_info_variant(add_qty=add_qty, pricelist=pricelist)"/>
+ <input type="radio" name="product_id" class="custom-control-input product_id 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="combination_info['list_price']" t-att-data-price="combination_info['price']" t-att-data-combination="variant_id.product_template_attribute_value_ids.ids"/>
<label t-attf-for="radio_variant_#{variant_id.id}" label-default="label-default" class="custom-control-label">
- <span t-esc="variant_id.display_name"/>
- <t t-set="diff_price" t-value="website.currency_id.compare_amounts(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(variant_id.price - variant_id.product_tmpl_id.price)" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
+ <span t-esc="combination_info['display_name']"/>
+ <t t-set="diff_price" t-value="website.currency_id.compare_amounts(combination_info['price'], template_combination_info['price'])"/>
+ <span class="badge badge-pill badge-secondary" t-if="diff_price != 0">
+ <t t-esc="diff_price > 0 and '+' or '-'"/><span t-esc="abs(combination_info['price'] - template_combination_info['price'])" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
</span>
</label>
</div>
diff --git a/addons/website_sale_comparison/views/website_sale_comparison_template.xml b/addons/website_sale_comparison/views/website_sale_comparison_template.xml
index ecf16c94bcb..26468eb57fa 100644
--- a/addons/website_sale_comparison/views/website_sale_comparison_template.xml
+++ b/addons/website_sale_comparison/views/website_sale_comparison_template.xml
@@ -10,8 +10,8 @@
<template id="add_to_compare" inherit_id="website_sale.products_item" active="True" customize_show="True" name="Comparison List" priority="22">
<xpath expr="//div[hasclass('product_price')]" position="inside">
- <t t-set="product_variant" t-value="product_variant or product._create_first_product_variant()"/>
- <button t-if="product_variant" type="button" role="button" class="d-none d-md-inline-block btn btn-secondary btn-sm o_add_compare" title="Compare" aria-label="Compare" t-att-data-product-product-id="product_variant.id" data-action="o_comparelist"><span class="fa fa-exchange"></span></button>
+ <t t-set="product_variant_id" t-value="product._get_first_possible_variant_id()"/>
+ <button t-if="product_variant_id" type="button" role="button" class="d-none d-md-inline-block btn btn-secondary btn-sm o_add_compare" title="Compare" aria-label="Compare" t-att-data-product-product-id="product_variant_id" data-action="o_comparelist"><span class="fa fa-exchange"></span></button>
</xpath>
</template>
@@ -66,7 +66,7 @@
<t t-set="product_variant" t-value="product_variant or product._create_first_product_variant()"/>
<t t-set="id_list" t-value="[product_variant.id] if product_variant else []"/>
<t t-foreach="product.alternative_product_ids" t-as="alt_product">
- <t t-set="alt_product_variant_id" t-value="alt_product._get_combination_info()['product_id'] or alt_product._create_first_product_variant().id"/>
+ <t t-set="alt_product_variant_id" t-value="alt_product._create_first_product_variant().id"/>
<t t-if="alt_product_variant_id" t-set="id_list" t-value="id_list + [alt_product_variant_id]"/>
</t>
<t t-set="ids" t-value="','.join([str(id) for id in id_list])"/>
diff --git a/addons/website_sale_slides/data/website_sale_slides_demo.xml b/addons/website_sale_slides/data/website_sale_slides_demo.xml
index 8f9aa1c7613..ae4d879b9dd 100644
--- a/addons/website_sale_slides/data/website_sale_slides_demo.xml
+++ b/addons/website_sale_slides/data/website_sale_slides_demo.xml
@@ -1,78 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
-<odoo>
- <data>
- <record id="product_course" model="product.product">
- <field name="name">High-Level DIY Furniture</field>
- <field name="standard_price">100.0</field>
- <field name="list_price">100.0</field>
- <field name="type">service</field>
- <field name="website_published" eval="True"/>
- <field name="image" type="base64" file="website_sale_slides/static/img/product_course.png"/>
- </record>
+<odoo><data noupdate="1">
- <record id="slide_channel_demo_6_furn3_payment" model="slide.channel">
- <field name="name">High-Level DIY Furniture</field>
- <field name="promote_strategy">most_viewed</field>
- <field name="channel_type">training</field>
- <field name="enroll">payment</field>
- <field name="product_id" ref="product_course"/>
- <field name="tag_ids" eval="[(5, 0),
- (4, ref('website_slides.slide_channel_tag_level_advanced')),
- (4, ref('website_slides.slide_channel_tag_role_carpenter')),
- (4, ref('website_slides.slide_channel_tag_role_furniture')),
- (4, ref('website_slides.slide_channel_tag_other_2'))]"/>
- <field name="website_published" eval="True"/>
- <field name="image" type="base64" file="website_sale_slides/static/img/channel_demo_diy.jpg"/>
- <field name="description" type="html"><div>All you need to know about furniture creation.</div></field>
- </record>
- <record id="slide_category_demo_6_0" model="slide.category">
- <field name="name">Tools and Methods</field>
- <field name="channel_id" ref="website_sale_slides.slide_channel_demo_6_furn3_payment"/>
- <field name="sequence">10</field>
- </record>
- <record id="slide_category_demo_6_1" model="slide.category">
- <field name="name">Hand on !</field>
- <field name="channel_id" ref="website_sale_slides.slide_channel_demo_6_furn3_payment"/>
- <field name="sequence">20</field>
- </record>
+ <!-- CHANNEL 6: DIY Furniture -->
+ <!-- ================================================== -->
+ <record id="product_course" model="product.product">
+ <field name="name">DIY Furniture Course</field>
+ <field name="standard_price">100.0</field>
+ <field name="list_price">100.0</field>
+ <field name="type">service</field>
+ <field name="website_published" eval="True"/>
+ <field name="image" type="base64" file="website_sale_slides/static/img/product_course.png"/>
+ </record>
- <record id="slide_slide_demo_6_0" model="slide.slide">
- <field name="name">Unforgettable Tools</field>
- <field name="datas" type="base64" file="website_slides/static/src/img/presentation.pdf"/>
- <field name="image" type="base64" file="website_slides/static/src/img/channel-training-default.jpg"/>
- <field name="slide_type">presentation</field>
- <field name="channel_id" ref="slide_channel_demo_6_furn3_payment"/>
- <field name="category_id" ref="website_sale_slides.slide_category_demo_6_0"/>
- <field name="website_published" eval="True"/>
- <field name="is_preview" eval="False"/>
- <field name="public_views">10</field>
- <field name="description">Tools you will need to complete this course.</field>
- </record>
- <record id="slide_slide_demo_6_1" model="slide.slide">
- <field name="name">How to find quality wood</field>
- <field name="url">https://www.youtube.com/watch?v=5WMqwTnZ-qs</field>
- <field name="document_id">5WMqwTnZ-qs</field>
- <field name="slide_type">video</field>
- <field name="channel_id" ref="slide_channel_demo_6_furn3_payment"/>
- <field name="category_id" ref="website_sale_slides.slide_category_demo_6_1"/>
- <field name="website_published" eval="True"/>
- <field name="is_preview" eval="False"/>
- <field name="public_views">5</field>
- <field name="image" type="base64" file="website_sale_slides/static/img/5WMqwTnZ-qs.png"/>
- <field name="description">Learn of to identify quality wood in order to create solid furnitures.</field>
- </record>
- <record id="slide_slide_demo_6_2" model="slide.slide">
- <field name="name">How to create your own piece of furniture</field>
- <field name="url">https://www.youtube.com/watch?v=ptjeDDoURL8</field>
- <field name="document_id">ptjeDDoURL8</field>
- <field name="slide_type">video</field>
- <field name="channel_id" ref="slide_channel_demo_6_furn3_payment"/>
- <field name="category_id" ref="website_sale_slides.slide_category_demo_6_1"/>
- <field name="website_published" eval="True"/>
- <field name="is_preview" eval="False"/>
- <field name="public_views">0</field>
- <field name="image" type="base64" file="website_sale_slides/static/img/ptjeDDoURL8.png"/>
- <field name="description">From a piece of wood to a fully functional furniture, step by step.</field>
- </record>
- </data>
-</odoo>
+ <record id="website_slides.slide_channel_demo_6_furn3" model="slide.channel">
+ <field name="enroll">payment</field>
+ <field name="product_id" ref="product_course"/>
+ </record>
+
+</data></odoo>
diff --git a/addons/website_sale_slides/static/img/5WMqwTnZ-qs.png b/addons/website_sale_slides/static/img/5WMqwTnZ-qs.png
deleted file mode 100644
index 1bbec207f29..00000000000
--- a/addons/website_sale_slides/static/img/5WMqwTnZ-qs.png
+++ /dev/null
Binary files differ
diff --git a/addons/website_sale_slides/static/img/ptjeDDoURL8.png b/addons/website_sale_slides/static/img/ptjeDDoURL8.png
deleted file mode 100644
index dc3c43dd8a6..00000000000
--- a/addons/website_sale_slides/static/img/ptjeDDoURL8.png
+++ /dev/null
Binary files differ
diff --git a/addons/website_sale_stock/controllers/main.py b/addons/website_sale_stock/controllers/main.py
index 386acc30ba9..58442ef9471 100644
--- a/addons/website_sale_stock/controllers/main.py
+++ b/addons/website_sale_stock/controllers/main.py
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from odoo import http
from odoo.addons.website_sale.controllers.main import WebsiteSale
from odoo.http import request
@@ -10,3 +11,8 @@ class WebsiteSale(WebsiteSale):
"""deprecated, use product method"""
combination = request.env['product.template.attribute.value'].browse(combination)
return request.env['product.template'].browse(int(product_template_id))._get_combination_info(combination, product_id, add_qty, pricelist)
+
+ @http.route()
+ def get_combination_info_website(self, product_template_id, product_id, combination, add_qty, **kw):
+ kw['context'] = {'website_sale_stock_get_quantity': True}
+ return super(WebsiteSale, self).get_combination_info_website(product_template_id, product_id, combination, add_qty, **kw)
diff --git a/addons/website_sale_stock/models/product_template.py b/addons/website_sale_stock/models/product_template.py
index d2d5f9e5759..ee8ea1e3ac3 100644
--- a/addons/website_sale_stock/models/product_template.py
+++ b/addons/website_sale_stock/models/product_template.py
@@ -17,8 +17,13 @@ class ProductTemplate(models.Model):
custom_message = fields.Text(string='Custom Message', default='')
@api.multi
- def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, reference_product=False):
- combination_info = super(ProductTemplate, self)._get_combination_info(combination, product_id, add_qty, pricelist, reference_product)
+ def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, reference_product=False, only_template=False):
+ combination_info = super(ProductTemplate, self)._get_combination_info(
+ combination=combination, product_id=product_id, add_qty=add_qty, pricelist=pricelist,
+ parent_combination=reference_product, only_template=only_template)
+
+ if not self.env.context.get('website_sale_stock_get_quantity'):
+ return combination_info
if combination_info['product_id']:
product = self.env['product.product'].sudo().browse(combination_info['product_id'])
diff --git a/addons/website_sale_wishlist/views/website_sale_wishlist_template.xml b/addons/website_sale_wishlist/views/website_sale_wishlist_template.xml
index 0bd94c653c4..c7618596750 100644
--- a/addons/website_sale_wishlist/views/website_sale_wishlist_template.xml
+++ b/addons/website_sale_wishlist/views/website_sale_wishlist_template.xml
@@ -12,8 +12,8 @@
<template id="add_to_wishlist" inherit_id="website_sale.products_item" active="True" customize_show="True" name="Wishlist" priority="20">
<xpath expr="//div[hasclass('product_price')]" position="inside">
<t t-set="in_wish" t-value="product._is_in_wishlist()"/>
- <t t-set="product_variant" t-value="product_variant or product._create_first_product_variant()"/>
- <button t-if="product_variant" type="button" role="button" class="btn btn-secondary btn-sm o_add_wishlist" t-att-disabled='in_wish or None' title="Add to Wishlist" t-att-data-product-template-id="product.id" t-att-data-product-product-id="product_variant.id" data-action="o_wishlist"><span class="fa fa-heart" role="img" aria-label="Add to wishlist"></span></button>
+ <t t-set="product_variant_id" t-value="product._get_first_possible_variant_id()"/>
+ <button t-if="product_variant_id" type="button" role="button" class="btn btn-secondary btn-sm o_add_wishlist" t-att-disabled='in_wish or None' title="Add to Wishlist" t-att-data-product-template-id="product.id" t-att-data-product-product-id="product_variant_id" data-action="o_wishlist"><span class="fa fa-heart" role="img" aria-label="Add to wishlist"></span></button>
</xpath>
</template>
diff --git a/addons/website_slides/controllers/main.py b/addons/website_slides/controllers/main.py
index d829f22d2fd..f8ebb91a70b 100644
--- a/addons/website_slides/controllers/main.py
+++ b/addons/website_slides/controllers/main.py
@@ -5,6 +5,7 @@ import base64
import itertools
import logging
import werkzeug
+import math
from odoo import http, modules, tools, _
from odoo.exceptions import AccessError, UserError
@@ -19,8 +20,9 @@ _logger = logging.getLogger(__name__)
class WebsiteSlides(WebsiteProfile):
- _slides_per_page = 12
- _slides_per_list = 20
+ SLIDES_PER_PAGE = 12
+ SLIDES_PER_ASIDE = 20
+ SLIDES_PER_CATEGORY = 4
_channel_order_by_criterion = {
'vote': 'total_votes desc',
'view': 'total_views desc',
@@ -36,6 +38,9 @@ class WebsiteSlides(WebsiteProfile):
if not qs or qs.lower() in loc:
yield {'loc': loc}
+ # SLIDE UTILITIES
+ # --------------------------------------------------
+
def _fetch_slide(self, slide_id):
slide = request.env['slide.slide'].browse(int(slide_id)).exists()
if not slide:
@@ -47,54 +52,117 @@ class WebsiteSlides(WebsiteProfile):
return {'error': 'slide_access'}
return {'slide': slide}
- def _set_viewed_slide(self, slide):
- if request.env.user._is_public() or slide.is_preview or not slide.channel_id.is_member:
+ def _set_viewed_slide(self, slide, quiz_attempts_inc=False):
+ if request.env.user._is_public() or not slide.website_published or not slide.channel_id.is_member:
viewed_slides = request.session.setdefault('viewed_slides', list())
if slide.id not in viewed_slides:
slide.sudo().public_views += 1
viewed_slides.append(slide.id)
request.session['viewed_slides'] = viewed_slides
else:
- slide.action_set_viewed()
+ slide.action_set_viewed(quiz_attempts_inc=quiz_attempts_inc)
return True
- def _set_completed_slide(self, slide):
+ def _set_completed_slide(self, slide, quiz_attempts_inc=False):
if slide.website_published and slide.channel_id.is_member:
- slide.action_set_completed()
+ slide.action_set_completed(quiz_attempts_inc=quiz_attempts_inc)
return True
def _get_slide_detail(self, slide):
- most_viewed_slides = slide._get_most_viewed_slides(self._slides_per_list)
- related_slides = slide._get_related_slides(self._slides_per_list)
+ base_domain = self._get_channel_slides_base_domain(slide.channel_id)
+ if slide.channel_id.channel_type == 'documentation':
+ related_domain = expression.AND([base_domain, [('category_id', '=', slide.category_id.id)]])
+
+ most_viewed_slides = request.env['slide.slide'].search(base_domain, limit=self.SLIDES_PER_ASIDE, order='total_views desc')
+ related_slides = request.env['slide.slide'].search(related_domain, limit=self.SLIDES_PER_ASIDE)
+ category_data = []
+ uncategorized_slides = request.env['slide.slide']
+ else:
+ most_viewed_slides, related_slides = request.env['slide.slide'], request.env['slide.slide']
+ category_data = slide.channel_id._get_categorized_slides(
+ base_domain, order=request.env['slide.slide']._order_by_strategy['sequence'],
+ force_void=True)
+ # temporarily kept for fullscreen, to remove asap
+ uncategorized_domain = expression.AND([base_domain, [('channel_id', '=', slide.channel_id.id), ('category_id', '=', False)]])
+ uncategorized_slides = request.env['slide.slide'].search(uncategorized_domain)
+
+ channel_slides_ids = slide.channel_id.slide_ids.ids
+ slide_index = channel_slides_ids.index(slide.id)
+ previous_slide = slide.channel_id.slide_ids[slide_index-1] if slide_index > 0 else None
+ next_slide = slide.channel_id.slide_ids[slide_index+1] if slide_index < len(channel_slides_ids) - 1 else None
+
values = {
+ # slide
'slide': slide,
'most_viewed_slides': most_viewed_slides,
'related_slides': related_slides,
+ 'previous_slide': previous_slide,
+ 'next_slide': next_slide,
+ 'uncategorized_slides': uncategorized_slides,
+ 'category_data': category_data,
+ # user
'user': request.env.user,
'is_public_user': request.website.is_public_user(),
+ # rating and comments
'comments': slide.website_message_ids or [],
- 'user_progress': {}
}
- if slide.channel_id.channel_type == "training":
- channel_slides_ids = slide.channel_id.slide_ids.ids
- slide_index = channel_slides_ids.index(slide.id)
- previous_slide = slide.channel_id.slide_ids[slide_index-1] if slide_index > 0 else None
- next_slide = slide.channel_id.slide_ids[slide_index+1] if slide_index < len(channel_slides_ids) - 1 else None
+
+ # allow rating and comments
+ if slide.channel_id.allow_comment:
values.update({
- 'previous_slide': slug(previous_slide) if previous_slide else "",
- 'next_slide': slug(next_slide) if next_slide else ""
+ 'message_post_hash': slide._generate_signed_token(request.env.user.partner_id.id),
+ 'message_post_pid': request.env.user.partner_id.id,
})
+
return values
- def _get_quiz_points(self, slide, attempt_count):
- possible_points = [slide.quiz_first_attempt_reward,slide.quiz_second_attempt_reward,slide.quiz_third_attempt_reward, slide.quiz_fourth_attempt_reward]
- return possible_points[attempt_count] if attempt_count < len(possible_points) else possible_points[-1]
+ def _get_slide_quiz_info(self, slide, quiz_done=False):
+ return slide._compute_quiz_info(request.env.user.partner_id, quiz_done=quiz_done)[slide.id]
- def _get_user_progress(self, channel):
- user_progress = { slide_partner.slide_id.id: slide_partner for slide_partner in request.env['slide.slide.partner'].sudo().search([('channel_id', '=', channel.id),('partner_id', '=', request.env.user.partner_id.id)])}
- return {
- 'user_progress': user_progress
- }
+ # CHANNEL UTILITIES
+ # --------------------------------------------------
+
+ def _get_channel_slides_base_domain(self, channel):
+ """ base domain when fetching slide list data related to a given channel
+
+ * website related domain, and restricted to the channel;
+ * if publisher: everything is ok;
+ * if not publisher but has user: either slide is published, either
+ current user is the one that uploaded it;
+ * if not publisher and public: published;
+ """
+ base_domain = expression.AND([request.website.website_domain(), [('channel_id', '=', channel.id)]])
+ if not channel.can_publish:
+ if request.website.is_public_user():
+ base_domain = expression.AND([base_domain, [('website_published', '=', True)]])
+ else:
+ base_domain = expression.AND([base_domain, ['|', ('website_published', '=', True), ('user_id', '=', request.env.user.id)]])
+ return base_domain
+
+ def _get_channel_progress(self, channel, include_quiz=False):
+ """ Replacement to user_progress. Both may exist in some transient state. """
+ slides = request.env['slide.slide'].sudo().search([('channel_id', '=', channel.id)])
+ channel_progress = dict((sid, dict()) for sid in slides.ids)
+ if not request.env.user._is_public() and channel.is_member:
+ slide_partners = request.env['slide.slide.partner'].sudo().search([
+ ('channel_id', '=', channel.id),
+ ('partner_id', '=', request.env.user.partner_id.id)
+ ])
+ for slide_partner in slide_partners:
+ channel_progress[slide_partner.slide_id.id].update(slide_partner.read()[0])
+ if slide_partner.slide_id.question_ids:
+ gains = [slide_partner.slide_id.quiz_first_attempt_reward,
+ slide_partner.slide_id.quiz_second_attempt_reward,
+ slide_partner.slide_id.quiz_third_attempt_reward,
+ slide_partner.slide_id.quiz_fourth_attempt_reward]
+ channel_progress[slide_partner.slide_id.id]['quiz_gain'] = gains[slide_partner.quiz_attempts_count] if slide_partner.quiz_attempts_count < len(gains) else gains[-1]
+
+ if include_quiz:
+ quiz_info = slides._compute_quiz_info(request.env.user.partner_id, quiz_done=False)
+ for slide_id, slide_info in quiz_info.items():
+ channel_progress[slide_id].update(slide_info)
+
+ return channel_progress
def _extract_channel_tag_search(self, **post):
tags = request.env['slide.channel.tag']
@@ -133,11 +201,8 @@ class WebsiteSlides(WebsiteProfile):
domain = expression.AND([domain, [('partner_ids', '=', request.env.user.partner_id.id)]])
return domain
- def _prepare_channel_values(self, **kwargs):
- return dict(**kwargs)
-
# --------------------------------------------------
- # MAIN / SEARCH
+ # SLIDE.CHANNEL MAIN / SEARCH
# --------------------------------------------------
@http.route('/slides', type='http', auth="public", website=True)
@@ -146,7 +211,7 @@ class WebsiteSlides(WebsiteProfile):
domain = request.website.website_domain()
channels_all = request.env['slide.channel'].search(domain)
if not request.env.user._is_public():
- channels_my = channels_all.filtered(lambda channel: channel.completion > 0).sorted('completion', reverse=True)[:3]
+ channels_my = channels_all.filtered(lambda channel: channel.is_member).sorted('completion', reverse=True)[:3]
else:
channels_my = request.env['slide.channel']
channels_popular = channels_all.sorted('total_votes', reverse=True)[:3]
@@ -244,12 +309,7 @@ class WebsiteSlides(WebsiteProfile):
if not channel.can_access_from_current_website():
raise werkzeug.exceptions.NotFound()
- domain = [('channel_id', '=', channel.id)]
- if not channel.can_publish:
- domain = expression.AND([
- domain,
- ['&', ('website_published', '=', True), ('channel_id.website_published', '=', True)]
- ])
+ domain = self._get_channel_slides_base_domain(channel)
pager_url = "/slides/%s" % (channel.id)
pager_args = {}
@@ -278,14 +338,18 @@ class WebsiteSlides(WebsiteProfile):
pager_url += "?slide_type=%s" % slide_type
# sorting criterion
- actual_sorting = sorting if sorting and sorting in request.env['slide.slide']._order_by_strategy else channel.promote_strategy
+ if channel.channel_type == 'documentation':
+ actual_sorting = sorting if sorting and sorting in request.env['slide.slide']._order_by_strategy else channel.promote_strategy
+ else:
+ actual_sorting = 'sequence'
order = request.env['slide.slide']._order_by_strategy[actual_sorting]
pager_args['sorting'] = actual_sorting
- pager_count = request.env['slide.slide'].sudo().search_count(domain)
- pager = request.website.pager(url=pager_url, total=pager_count, page=page,
- step=self._slides_per_page, scope=self._slides_per_page,
- url_args=pager_args)
+ slide_count = request.env['slide.slide'].sudo().search_count(domain)
+ page_count = math.ceil(slide_count / self.SLIDES_PER_PAGE)
+ pager = request.website.pager(url=pager_url, total=slide_count, page=page,
+ step=self.SLIDES_PER_PAGE, url_args=pager_args,
+ scope=page_count if page_count < self.PAGER_MAX_PAGE else self.PAGER_MAX_PAGE)
values = {
'channel': channel,
@@ -323,63 +387,26 @@ class WebsiteSlides(WebsiteProfile):
'last_rating_value': last_message_data.get('rating_value'),
})
- # Display uncategorized slides
- # fetch slides; done as sudo because we want to display all of them but
- # unreachable ones won't be clickable (+ slide controller will crash anyway)
- if not category and not uncategorized:
- category_data = []
- for category in request.env['slide.slide'].sudo().read_group(domain, ['category_id'], ['category_id']):
- category_id, name = category.get('category_id') or (False, _('Uncategorized'))
- slides_sudo = request.env['slide.slide'].sudo().search(category['__domain'], limit=4, offset=0, order=order)
- category_data.append({
- 'id': category_id,
- 'name': name,
- 'slug_name': slug((category_id, name)) if category_id else name,
- 'total_slides': category['category_id_count'],
- 'slides': slides_sudo,
- })
- elif uncategorized:
- slides_sudo = request.env['slide.slide'].sudo().search(domain, limit=self._slides_per_page, offset=pager['offset'], order=order)
- category_data = [{
- 'id': False,
- 'name': _('Uncategorized'),
- 'slug_name': _('Uncategorized'),
- 'total_slides': len(slides_sudo),
- 'slides': slides_sudo,
- }]
- else:
- slides_sudo = request.env['slide.slide'].sudo().search(domain, limit=self._slides_per_page, offset=pager['offset'], order=order)
- category_data = [{
- 'id': category.id,
- 'name': category.name,
- 'slug_name': slug(category),
- 'total_slides': len(slides_sudo),
- 'slides': slides_sudo,
- }]
-
- # post slide-fetch computation: promoted, user completion (separated because sudo-ed)
- if not request.website.is_public_user() and channel.is_member:
- displayed_slide_ids = list(set(sid for item in category_data for sid in item['slides'].ids))
- done_slide_ids = request.env['slide.slide.partner'].sudo().search([
- ('slide_id', 'in', displayed_slide_ids),
- ('partner_id', '=', request.env.user.partner_id.id),
- ('completed', '=', True)
- ]).mapped('slide_id').ids
- else:
- done_slide_ids = []
- values['done_slide_ids'] = done_slide_ids
+ # fetch slides and handle uncategorized slides; done as sudo because we want to display all
+ # of them but unreachable ones won't be clickable (+ slide controller will crash anyway)
+ # documentation mode may display less slides than content by category but overhead of
+ # computation is reasonable
values['slide_promoted'] = request.env['slide.slide'].sudo().search(domain, limit=1, order=order)
- values['category_data'] = category_data
+ values['category_data'] = channel._get_categorized_slides(
+ domain, order,
+ force_void=True,
+ limit=self.SLIDES_PER_CATEGORY if channel.channel_type == 'documentation' else False,
+ offset=pager['offset'])
+ values['channel_progress'] = self._get_channel_progress(channel, include_quiz=True)
values = self._prepare_additional_channel_values(values, **kw)
- if channel.channel_type == "training":
- values.update(self._get_user_progress(channel))
- values['uncategorized_slides'] = channel.slide_ids.filtered(lambda slide: not slide.category_id)
-
return request.render('website_slides.course_main', values)
- @http.route(['/slides/channel/add'], type='http', auth='user', methods=['POST'], website=True)
+ # SLIDE.CHANNEL UTILS
+ # --------------------------------------------------
+
+ @http.route('/slides/channel/add', type='http', auth='user', methods=['POST'], website=True)
def slide_channel_create(self, *args, **kw):
channel = request.env['slide.channel'].create(self._slide_channel_prepare_values(**kw))
return werkzeug.utils.redirect("/slides/%s" % (slug(channel)))
@@ -400,30 +427,69 @@ class WebsiteSlides(WebsiteProfile):
'allow_comment': bool(kw.get('allow_comment')),
}
+ @http.route('/slides/channel/enroll', type='http', auth='public', website=True)
+ def slide_channel_join_http(self, channel_id):
+ # TDE FIXME: why 2 routes ?
+ if not request.website.is_public_user():
+ channel = request.env['slide.channel'].browse(int(channel_id))
+ channel.action_add_member()
+ return werkzeug.utils.redirect("/slides/%s" % (slug(channel)))
+
+ @http.route(['/slides/channel/join'], type='json', auth='public', website=True)
+ def slide_channel_join(self, channel_id):
+ if request.website.is_public_user():
+ return {'error': 'public_user'}
+ success = request.env['slide.channel'].browse(channel_id).action_add_member()
+ if not success:
+ return {'error': 'join_done'}
+ return success
+
+ @http.route('/slides/channel/resequence', type="json", website=True, auth="user")
+ def resequence_slides(self, channel_id, slides_data):
+ """" Reorder the slides within the channel by reassigning their 'sequence' field.
+ This method also handles slides that are put in a new category (or uncategorized). """
+ channel = request.env['slide.channel'].browse(int(channel_id))
+ if not channel.can_publish:
+ return {'error': 'Only the publishers of the channel can edit it'}
+
+ slides = request.env['slide.slide'].search([
+ ('id', 'in', [int(key) for key in slides_data.keys()]),
+ ('channel_id', '=', channel.id)
+ ])
+
+ for slide in slides:
+ slide_key = str(slide.id)
+ slide.sequence = slides_data[slide_key]['sequence']
+ slide.category_id = slides_data[slide_key]['category_id'] if 'category_id' in slides_data[slide_key] else False
+
+ @http.route(['/slides/channel/tag/search_read'], type='json', auth='user', methods=['POST'], website=True)
+ def slide_channel_tag_search_read(self, fields, domain):
+ can_create = request.env['slide.channel.tag'].check_access_rights('create', raise_exception=False)
+ return {
+ 'read_results': request.env['slide.channel.tag'].search_read(domain, fields),
+ 'can_create': can_create,
+ }
+
# --------------------------------------------------
- # SLIDE.SLIDE CONTOLLERS
+ # SLIDE.SLIDE MAIN / SEARCH
# --------------------------------------------------
@http.route('''/slides/slide/<model("slide.slide", "[('website_id', 'in', (False, current_website_id))]"):slide>''', type='http', auth="public", website=True)
def slide_view(self, slide, **kwargs):
if not slide.channel_id.can_access_from_current_website():
raise werkzeug.exceptions.NotFound()
-
self._set_viewed_slide(slide)
+
values = self._get_slide_detail(slide)
- # allow rating and comments
- if slide.channel_id.allow_comment:
- values.update({
- 'message_post_hash': slide._generate_signed_token(request.env.user.partner_id.id),
- 'message_post_pid': request.env.user.partner_id.id,
- })
- self._set_viewed_slide(slide)
- if slide.channel_id.channel_type == "training":
- values.update(self._get_user_progress(slide.channel_id))
- values['uncategorized_slides'] = slide.channel_id.slide_ids.filtered(lambda slide: not slide.category_id)
- if 'fullscreen' in kwargs:
- return request.render("website_slides.slide_fullscreen", values)
- return request.render("website_slides.slide_detail_view", values)
+ # quiz-specific: update with karma and quiz information
+ if slide.question_ids:
+ values.update(self._get_slide_quiz_info(slide))
+ # sidebar: update with user channel progress
+ values['channel_progress'] = self._get_channel_progress(slide.channel_id, include_quiz=True)
+
+ if 'fullscreen' in kwargs:
+ return request.render("website_slides.slide_fullscreen", values)
+ return request.render("website_slides.slide_main", values)
@http.route('''/slides/slide/<model("slide.slide"):slide>/pdf_content''',
type='http', auth="public", website=True, sitemap=False)
@@ -463,87 +529,25 @@ class WebsiteSlides(WebsiteProfile):
response.status_code = status
return response
- @http.route('/slide/html_content/get', type="json", auth="public", website=True)
- def get_html_content(self, slide_id):
- slide = request.env['slide.slide'].browse(slide_id)
- return {
- 'html_content': slide.html_content
- }
+ # SLIDE.SLIDE UTILS
+ # --------------------------------------------------
- #SLIDE QUIZ CONTROLLERS
-
- @http.route('/slide/quiz/get', type="json", auth="public", website=True)
- def get_quiz(self, **kw):
- if 'slide_id' in kw:
- slide = request.env['slide.slide'].browse(kw['slide_id'])
- slide_partner = request.env['slide.slide.partner'].search([('slide_id', '=', slide.id), ('partner_id', '=', request.env.user.partner_id.id)])
- possible_points = [slide.quiz_first_attempt_reward,slide.quiz_second_attempt_reward,slide.quiz_third_attempt_reward, slide.quiz_fourth_attempt_reward]
- points = 0
- if slide_partner.quiz_attempts_count < len(possible_points):
- points = possible_points[slide_partner.quiz_attempts_count]
- else:
- points = possible_points[len(possible_points)-1]
- res = {
- 'questions':[
- {'title': question.question,
- 'id': question.id,
- 'answers': [{'text': answer.text_value, 'correct':answer.is_correct,'id': answer.id} for answer in question.answer_ids]
- } for question in slide.question_ids
- ],
- 'nb_attempts': slide_partner.quiz_attempts_count if slide_partner else 0,
- 'possible_rewards': possible_points,
- 'reward': points
- }
- return res
-
- @http.route('/slide/quiz/submit', type="json", auth="user", website=True)
- def submit_quiz(self, slide_id, answer_ids,**kw):
- slide = request.env['slide.slide'].browse(slide_id)
- good_answers = request.env['slide.answer'].search([('id', 'in', answer_ids), ('is_correct', '=', True)])
- bad_answers = request.env['slide.answer'].browse(answer_ids) - good_answers
- slide_partner = request.env['slide.slide.partner'].search([('slide_id', '=', slide_id), ('partner_id', '=', request.env.user.partner_id.id)])
- points = 0
- if not slide_partner:
- slide.action_set_viewed()
- if not slide_partner.completed:
- points = self._get_quiz_points(slide, slide_partner.quiz_attempts_count)
- slide_partner.sudo().write({
- 'quiz_attempts_count': slide_partner.quiz_attempts_count if not bad_answers else slide_partner.quiz_attempts_count + 1,
- 'points_won': points if not bad_answers else 0,
- 'completed': not bad_answers
- })
- user = {}
- if not bad_answers:
- request.env.user.sudo().add_karma(points)
- lower_bound = request.env.user.rank_id.karma_min
- upper_bound = request.env.user.next_rank_id.karma_min
- user= {
- 'lower_bound': lower_bound,
- 'upper_bound': upper_bound,
- 'karma': request.env.user.karma,
- 'progress_bar_percentage': 100 * ((request.env.user.karma - lower_bound) / (upper_bound - lower_bound))
- }
- return {
- 'goodAnswers': [good_answer.id for good_answer in good_answers],
- 'badAnswers': [bad_answer.id for bad_answer in bad_answers],
- 'passed': not bad_answers,
- 'points': points if not bad_answers else 0,
- 'attempts_count': slide_partner.quiz_attempts_count if slide_partner else 0,
- 'channel_completion': slide.channel_id.completion,
- 'user': user
- }
+ @http.route('/slides/slide/get_html_content', type="json", auth="public", website=True)
+ def get_html_content(self, slide_id):
+ fetch_res = self._fetch_slide(slide_id)
+ if fetch_res.get('error'):
+ return fetch_res
return {
- 'error': "You already passed this quiz"
+ 'html_content': fetch_res['slide'].html_content
}
- # --------------------------------------------------
- # SLIDE.SLIDE TOOLS CONTOLLERS
- # --------------------------------------------------
-
@http.route('/slides/slide/<model("slide.slide"):slide>/set_completed', website=True, type="http", auth="user")
- def slide_set_completed_and_redirect(self, slide, next_slide=None):
+ def slide_set_completed_and_redirect(self, slide, next_slide_id=None):
self._set_completed_slide(slide)
- return werkzeug.utils.redirect("/slides/slide/%s" % (next_slide if next_slide else slide.id))
+ next_slide = None
+ if next_slide_id:
+ next_slide = self._fetch_slide(next_slide_id).get('slide', None)
+ return werkzeug.utils.redirect("/slides/slide/%s" % (slug(next_slide) if next_slide else slug(slide)))
@http.route('/slides/slide/set_completed', website=True, type="json", auth="public")
def slide_set_completed(self, slide_id):
@@ -593,6 +597,76 @@ class WebsiteSlides(WebsiteProfile):
return result
# --------------------------------------------------
+ # QUIZZ SECTION
+ # --------------------------------------------------
+
+ @http.route('/slides/slide/quiz/get', type="json", auth="public", website=True)
+ def slide_quiz_get(self, slide_id):
+ fetch_res = self._fetch_slide(slide_id)
+ if fetch_res.get('error'):
+ return fetch_res
+ slide = fetch_res['slide']
+ quiz_info = self._get_slide_quiz_info(slide)
+ return {
+ 'questions': [{
+ 'id': question.id,
+ 'question': question.question,
+ 'answers': [{
+ 'id': answer.id,
+ 'text_value': answer.text_value,
+ 'is_correct': answer.is_correct,
+ } for answer in question.answer_ids],
+ } for question in slide.question_ids],
+ 'quizAttemptsCount': quiz_info['quiz_attempts_count'],
+ 'quizKarmaGain': quiz_info['quiz_karma_gain'],
+ 'quizKarmaWon': quiz_info['quiz_karma_won'],
+ }
+
+ @http.route('/slides/slide/quiz/submit', type="json", auth="user", website=True)
+ def slide_quiz_submit(self, slide_id, answer_ids):
+ fetch_res = self._fetch_slide(slide_id)
+ if fetch_res.get('error'):
+ return fetch_res
+ slide = fetch_res['slide']
+
+ if slide.user_membership_id.completed:
+ return {'error': 'slide_quiz_done'}
+
+ all_questions = request.env['slide.question'].sudo().search([('slide_id', '=', slide.id)])
+
+ user_answers = request.env['slide.answer'].sudo().search([('id', 'in', answer_ids)])
+ if user_answers.mapped('question_id') != all_questions:
+ return {'error': 'slide_quiz_incomplete'}
+
+ user_bad_answers = user_answers.filtered(lambda answer: not answer.is_correct)
+ user_good_answers = user_answers - user_bad_answers
+
+ self._set_viewed_slide(slide, quiz_attempts_inc=True)
+ quiz_info = self._get_slide_quiz_info(slide, quiz_done=True)
+
+ rank_progress = {}
+ if not user_bad_answers:
+ slide._action_set_quiz_done()
+ lower_bound = request.env.user.rank_id.karma_min
+ upper_bound = request.env.user.next_rank_id.karma_min
+ rank_progress = {
+ 'lowerBound': lower_bound,
+ 'upperBound': upper_bound,
+ 'currentKarma': request.env.user.karma,
+ 'motivational': request.env.user.next_rank_id.description_motivational,
+ 'progress': 100 * ((request.env.user.karma - lower_bound) / (upper_bound - lower_bound))
+ }
+ return {
+ 'goodAnswers': user_good_answers.ids,
+ 'badAnswers': user_bad_answers.ids,
+ 'completed': not user_bad_answers,
+ 'quizKarmaWon': quiz_info['quiz_karma_won'],
+ 'quizKarmaGain': quiz_info['quiz_karma_gain'],
+ 'quizAttemptsCount': quiz_info['quiz_attempts_count'],
+ 'rankProgress': rank_progress
+ }
+
+ # --------------------------------------------------
# CATEGORY MANAGEMENT
# --------------------------------------------------
@@ -626,44 +700,9 @@ class WebsiteSlides(WebsiteProfile):
return werkzeug.utils.redirect("/slides/%s" % (slug(channel)))
# --------------------------------------------------
- # TOOLS
+ # SLIDE.UPLOAD
# --------------------------------------------------
- @http.route(['/slides/channel/enroll'], type='http', auth='public', website=True)
- def slide_channel_join_http(self, channel_id):
- # TDE FIXME: why 2 routes ?
- if not request.website.is_public_user():
- channel = request.env['slide.channel'].browse(int(channel_id))
- channel.action_add_member()
- return werkzeug.utils.redirect("/slides/%s" % (slug(channel)))
-
- @http.route(['/slides/channel/join'], type='json', auth='public', website=True)
- def slide_channel_join(self, channel_id):
- if request.website.is_public_user():
- return {'error': 'public_user'}
- success = request.env['slide.channel'].browse(channel_id).action_add_member()
- if not success:
- return {'error': 'join_done'}
- return success
-
- @http.route('/slides/channel/resequence', type="json", website=True, auth="user")
- def resequence_slides(self, channel_id, slides_data):
- """" Reorder the slides within the channel by reassigning their 'sequence' field.
- This method also handles slides that are put in a new category (or uncategorized). """
- channel = request.env['slide.channel'].browse(int(channel_id))
- if not channel.can_publish:
- return {'error': 'Only the publishers of the channel can edit it'}
-
- slides = request.env['slide.slide'].search([
- ('id', 'in', [int(key) for key in slides_data.keys()]),
- ('channel_id', '=', channel.id)
- ])
-
- for slide in slides:
- slide_key = str(slide.id)
- slide.sequence = slides_data[slide_key]['sequence']
- slide.category_id = slides_data[slide_key]['category_id'] if 'category_id' in slides_data[slide_key] else False
-
@http.route(['/slides/prepare_preview'], type='json', auth='user', methods=['POST'], website=True)
def prepare_preview(self, **data):
Slide = request.env['slide.slide']
@@ -743,14 +782,6 @@ class WebsiteSlides(WebsiteProfile):
return ['name', 'url', 'tag_ids', 'slide_type', 'channel_id', 'is_preview',
'mime_type', 'datas', 'description', 'image', 'index_content', 'website_published']
- @http.route(['/slides/channel/tag/search_read'], type='json', auth='user', methods=['POST'], website=True)
- def slide_channel_tag_search_read(self, fields, domain):
- can_create = request.env['slide.channel.tag'].check_access_rights('create', raise_exception=False)
- return {
- 'read_results': request.env['slide.channel.tag'].search_read(domain, fields),
- 'can_create': can_create,
- }
-
@http.route(['/slides/tag/search_read'], type='json', auth='user', methods=['POST'], website=True)
def slide_tag_search_read(self, fields, domain):
can_create = request.env['slide.tag'].check_access_rights('create', raise_exception=False)
@@ -762,6 +793,7 @@ class WebsiteSlides(WebsiteProfile):
# --------------------------------------------------
# EMBED IN THIRD PARTY WEBSITES
# --------------------------------------------------
+
@http.route('/slides/embed/<int:slide_id>', type='http', auth='public', website=True, sitemap=False)
def slides_embed(self, slide_id, page="1", **kw):
# Note : don't use the 'model' in the route (use 'slide_id'), otherwise if public cannot access the embedded
@@ -776,7 +808,7 @@ class WebsiteSlides(WebsiteProfile):
try:
slide = request.env['slide.slide'].browse(slide_id)
if is_embedded:
- request.env['slide.embed'].sudo().add_embed_url(slide.id, referrer_url)
+ request.env['slide.embed'].sudo()._add_embed_url(slide.id, referrer_url)
values = self._get_slide_detail(slide)
values['page'] = page
values['is_embedded'] = is_embedded
diff --git a/addons/website_slides/data/slide_channel_demo.xml b/addons/website_slides/data/slide_channel_demo.xml
index 78e760228e0..1c6ae501db4 100644
--- a/addons/website_slides/data/slide_channel_demo.xml
+++ b/addons/website_slides/data/slide_channel_demo.xml
@@ -1,39 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
-<odoo><data noupdate="0">
+<odoo><data noupdate="1">
<record id="website_slides.channel_public" model="slide.channel">
<field name="name">Basics of Gardening</field>
+ <field name="enroll">public</field>
<field name="channel_type">training</field>
+ <field name="allow_comment" eval="True"/>
<field name="promote_strategy">most_voted</field>
<field name="tag_ids" eval="[(5, 0),
(4, ref('website_slides.slide_channel_tag_level_basic')),
(4, ref('website_slides.slide_channel_tag_role_gardener')),
- (4, ref('website_slides.slide_channel_tag_other_0'))]"/>
+ (4, ref('website_slides.slide_channel_tag_other_2'))]"/>
<field name="image" type="base64" file="website_slides/static/src/img/channel_demo_gardening.jpg"/>
<field name="description">Learn the basics of gardening !</field>
- </record>
- <record id="slide_category_demo_0_0" model="slide.category">
- <field name="name">Requirements</field>
- <field name="channel_id" ref="website_slides.channel_public"/>
- <field name="sequence">10</field>
- </record>
- <record id="slide_category_demo_0_1" model="slide.category">
- <field name="name">Tools</field>
- <field name="channel_id" ref="website_slides.channel_public"/>
- <field name="sequence">20</field>
+ <field name="create_date" eval="DateTime.now() - relativedelta(days=8)"/>
</record>
<record id="slide_channel_demo_1_gard1" model="slide.channel">
<field name="name">Taking care of Trees</field>
+ <field name="enroll">public</field>
<field name="channel_type">training</field>
- <field name="tag_ids" eval="[(5, 0),
- (4, ref('website_slides.slide_channel_tag_level_intermediate')),
- (4, ref('website_slides.slide_channel_tag_role_gardener'))]"/>
+ <field name="allow_comment" eval="True"/>
<field name="promote_strategy">latest</field>
<field name="website_published" eval="True"/>
- <field name="enroll">public</field>
+ <field name="tag_ids" eval="[(5, 0),
+ (4, ref('website_slides.slide_channel_tag_level_intermediate')),
+ (4, ref('website_slides.slide_channel_tag_role_gardener')),
+ (4, ref('website_slides.slide_channel_tag_other_0'))]"/>
<field name="image" type="base64" file="website_slides/static/src/img/channel_demo_gardening_2.jpg"/>
<field name="description">Learn how to take care of your favorite trees. Learn when to plant, how to manage potted trees, ...</field>
+ <field name="create_date" eval="DateTime.now() - relativedelta(days=7)"/>
</record>
<record id="slide_category_demo_1_0" model="slide.category">
<field name="name">Interesting Facts</field>
@@ -48,17 +44,20 @@
<record id="slide_channel_demo_2_gard2" model="slide.channel">
<field name="name">Trees, Wood and Gardens</field>
+ <field name="enroll">public</field>
<field name="channel_type">documentation</field>
+ <field name="allow_comment" eval="True"/>
+ <field name="promote_strategy">most_viewed</field>
+ <field name="website_published" eval="True"/>
<field name="tag_ids" eval="[(5, 0),
(4, ref('website_slides.slide_channel_tag_level_intermediate')),
(4, ref('website_slides.slide_channel_tag_role_gardener')),
(4, ref('website_slides.slide_channel_tag_role_carpenter')),
+ (4, ref('website_slides.slide_channel_tag_other_0')),
(4, ref('website_slides.slide_channel_tag_other_2'))]"/>
- <field name="promote_strategy">most_viewed</field>
- <field name="website_published" eval="True"/>
- <field name="enroll">public</field>
<field name="image" type="base64" file="website_slides/static/src/img/channel_demo_flowers.jpg"/>
<field name="description">A lot of nice documentation: trees, wood, gardens. A gold mine for references.</field>
+ <field name="create_date" eval="DateTime.now() - relativedelta(days=6)"/>
</record>
<record id="slide_category_demo_2_0" model="slide.category">
<field name="name">Trees</field>
@@ -73,19 +72,21 @@
<record id="slide_channel_demo_3_furn0" model="slide.channel">
<field name="name">Choose your wood !</field>
+ <field name="enroll">public</field>
<field name="channel_type">training</field>
+ <field name="allow_comment" eval="True"/>
+ <field name="promote_strategy">latest</field>
+ <field name="website_published" eval="True"/>
<field name="tag_ids" eval="[(5, 0),
(4, ref('website_slides.slide_channel_tag_level_intermediate')),
(4, ref('website_slides.slide_channel_tag_role_gardener')),
(4, ref('website_slides.slide_channel_tag_role_carpenter')),
(4, ref('website_slides.slide_channel_tag_role_furniture')),
- (4, ref('website_slides.slide_channel_tag_other_1'))]"/>
- <field name="promote_strategy">latest</field>
- <field name="website_published" eval="True"/>
- <field name="enroll">public</field>
+ (4, ref('website_slides.slide_channel_tag_other_2'))]"/>
<field name="image" type="base64" file="website_slides/static/src/img/channel_demo_tree_1.jpg"/>
<field name="description">Knowing which kind of wood to use depending on your application is important. In this course you
will learn the basics of wood characteristics.</field>
+ <field name="create_date" eval="DateTime.now() - relativedelta(days=5)"/>
</record>
<record id="slide_category_demo_3_0" model="slide.category">
<field name="name">Introduction</field>
@@ -95,15 +96,17 @@ will learn the basics of wood characteristics.</field>
<record id="slide_channel_demo_4_furn1" model="slide.channel">
<field name="name">Furniture Technical Specifications</field>
+ <field name="enroll">invite</field>
<field name="channel_type">documentation</field>
- <field name="tag_ids" eval="[(5, 0),
- (4, ref('website_slides.slide_channel_tag_level_intermediate')),
- (4, ref('website_slides.slide_channel_tag_role_furniture'))]"/>
+ <field name="allow_comment" eval="True"/>
<field name="promote_strategy">most_voted</field>
<field name="website_published" eval="True"/>
- <field name="enroll">invite</field>
+ <field name="tag_ids" eval="[(5, 0),
+ (4, ref('website_slides.slide_channel_tag_level_basic')),
+ (4, ref('website_slides.slide_channel_tag_role_furniture'))]"/>
<field name="image" type="base64" file="website_slides/static/src/img/channel_demo_furniture_2.jpg"/>
- <field name="description">We're also Santa Claus! Belligerent and numerous. I am the man with no name, Zapp Brannigan!</field>
+ <field name="description">If you are looking for technical specifications, have a look at this documentation.</field>
+ <field name="create_date" eval="DateTime.now() - relativedelta(days=4)"/>
</record>
<record id="slide_category_demo_4_0" model="slide.category">
<field name="name">Introduction</field>
@@ -113,23 +116,53 @@ will learn the basics of wood characteristics.</field>
<record id="slide_channel_demo_5_furn2" model="slide.channel">
<field name="name">Basics of Furniture Creation</field>
+ <field name="enroll">invite</field>
<field name="channel_type">training</field>
+ <field name="allow_comment" eval="True"/>
+ <field name="promote_strategy">latest</field>
+ <field name="website_published" eval="True"/>
<field name="tag_ids" eval="[(5, 0),
- (4, ref('website_slides.slide_channel_tag_level_basic')),
+ (4, ref('website_slides.slide_channel_tag_level_intermediate')),
(4, ref('website_slides.slide_channel_tag_role_furniture')),
+ (4, ref('website_slides.slide_channel_tag_other_0')),
(4, ref('website_slides.slide_channel_tag_other_1'))]"/>
- <field name="channel_type">training</field>
- <field name="promote_strategy">latest</field>
- <field name="website_published" eval="True"/>
- <field name="enroll">invite</field>
<field name="image" type="base64" file="website_slides/static/src/img/channel_demo_furniture.jpg"/>
<field name="description">All you need to know about furniture creation.</field>
+ <field name="create_date" eval="DateTime.now() - relativedelta(days=3)"/>
</record>
<record id="slide_category_demo_5_0" model="slide.category">
- <field name="name">Introduction</field>
+ <field name="name">Tools and Methods</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_5_furn2"/>
<field name="sequence">10</field>
</record>
+ <record id="slide_category_demo_5_1" model="slide.category">
+ <field name="name">Hand on !</field>
+ <field name="channel_id" ref="website_slides.slide_channel_demo_5_furn2"/>
+ <field name="sequence">20</field>
+ </record>
+ <record id="slide_category_demo_5_2" model="slide.category">
+ <field name="name">Test Yourself</field>
+ <field name="channel_id" ref="website_slides.slide_channel_demo_5_furn2"/>
+ <field name="sequence">30</field>
+ </record>
+
+ <!-- This channel will be set on payment and receive certifications capabilities -->
+ <record id="slide_channel_demo_6_furn3" model="slide.channel">
+ <field name="name">DIY Furniture</field>
+ <field name="enroll">invite</field>
+ <field name="channel_type">training</field>
+ <field name="allow_comment" eval="True"/>
+ <field name="promote_strategy">most_voted</field>
+ <field name="website_published" eval="True"/>
+ <field name="tag_ids" eval="[(5, 0),
+ (4, ref('website_slides.slide_channel_tag_level_advanced')),
+ (4, ref('website_slides.slide_channel_tag_role_carpenter')),
+ (4, ref('website_slides.slide_channel_tag_role_furniture')),
+ (4, ref('website_slides.slide_channel_tag_other_1'))]"/>
+ <field name="image" type="base64" file="website_slides/static/src/img/channel_demo_diy.jpg"/>
+ <field name="description">So much amazing certification.</field>
+ <field name="create_date" eval="DateTime.now() - relativedelta(days=2)"/>
+ </record>
<record id="slide_tag_demo_cheatsheet" model="slide.tag">
<field name="name">CheatSheet</field>
diff --git a/addons/website_slides/data/slide_slide_demo.xml b/addons/website_slides/data/slide_slide_demo.xml
index 80c45a5313f..9407a408671 100644
--- a/addons/website_slides/data/slide_slide_demo.xml
+++ b/addons/website_slides/data/slide_slide_demo.xml
@@ -1,15 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
-<odoo><data noupdate="0">
+<odoo><data noupdate="1">
<!-- CHANNEL 0: Basics of Gardening -->
<!-- ================================================== -->
<record id="slide_slide_demo_0_0" model="slide.slide">
<field name="name">Know-How Summary</field>
+ <field name="sequence">1</field>
<field name="datas" type="base64" file="website_slides/static/src/img/presentation.pdf"/>
<field name="image" type="base64" file="website_slides/static/src/img/channel-training-default.jpg"/>
<field name="slide_type">presentation</field>
<field name="channel_id" ref="website_slides.channel_public"/>
- <field name="category_id" ref="website_slides.slide_category_demo_0_0"/>
+ <field name="category_id" eval="False"/>
<field name="website_published" eval="True"/>
<field name="is_preview" eval="True"/>
<field name="public_views">10</field>
@@ -17,11 +18,26 @@
<field name="tag_ids" eval="[(4, ref('website_slides.slide_tag_demo_tools')), (4, ref('website_slides.slide_tag_demo_howto'))]"/>
<field name="description">A summary of know-how: how and what.</field>
</record>
+ <record id="slide_slide_demo_0_1" model="slide.slide">
+ <field name="name">Home Gardening</field>
+ <field name="sequence">2</field>
+ <field name="image" type="base64" file="website_slides/static/src/img/slide_demo_gardening_1.jpg"/>
+ <field name="slide_type">infographic</field>
+ <field name="channel_id" ref="website_slides.channel_public"/>
+ <field name="category_id" eval="False"/>
+ <field name="website_published" eval="True"/>
+ <field name="is_preview" eval="False"/>
+ <field name="public_views">5</field>
+ <field name="completion_time">1</field>
+ <field name="tag_ids" eval="[(4, ref('website_slides.slide_tag_demo_colorful')), (4, ref('website_slides.slide_tag_demo_theory'))]"/>
+ <field name="description">Interesting informations about home gardening. Keep it close !</field>
+ </record>
<!-- CHANNEL 1: Taking care of Trees -->
<!-- ================================================== -->
<record id="slide_slide_demo_1_0" model="slide.slide">
<field name="name">Tree Infographic</field>
+ <field name="sequence">1</field>
<field name="image" type="base64" file="website_slides/static/src/img/slide_demo_tree_infographic_1.jpg"/>
<field name="slide_type">infographic</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_1_gard1"/>
@@ -35,6 +51,7 @@
</record>
<record id="slide_slide_demo_1_1" model="slide.slide">
<field name="name">Interesting Tree Facts</field>
+ <field name="sequence">2</field>
<field name="image" type="base64" file="website_slides/static/src/img/slide_demo_tree_infographic_2.jpg"/>
<field name="slide_type">infographic</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_1_gard1"/>
@@ -42,11 +59,13 @@
<field name="website_published" eval="True"/>
<field name="is_preview" eval="False"/>
<field name="public_views">5</field>
+ <field name="completion_time">1.5</field>
<field name="tag_ids" eval="[(4, ref('website_slides.slide_tag_demo_colorful'))]"/>
<field name="description">Just some basics Interesting Tree Facts.</field>
</record>
<record id="slide_slide_demo_1_2" model="slide.slide">
<field name="name">Energy Efficiency Facts</field>
+ <field name="sequence">3</field>
<field name="image" type="base64" file="website_slides/static/src/img/slide_demo_tree_infographic_3.jpg"/>
<field name="slide_type">infographic</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_1_gard1"/>
@@ -54,11 +73,23 @@
<field name="website_published" eval="True"/>
<field name="is_preview" eval="False"/>
<field name="public_views">10</field>
+ <field name="completion_time">1</field>
<field name="tag_ids" eval="[(4, ref('website_slides.slide_tag_demo_colorful')), (4, ref('website_slides.slide_tag_demo_theory'))]"/>
<field name="description">Just some basics Energy Efficiency Facts.</field>
</record>
+ <record id="slide_slide_demo_1_2_link_0" model="slide.slide.link">
+ <field name="name">Energy Efficient Link 1</field>
+ <field name="link">http://www.example.com</field>
+ <field name="slide_id" ref="slide_slide_demo_1_2"/>
+ </record>
+ <record id="slide_slide_demo_1_2_link_1" model="slide.slide.link">
+ <field name="name">Energy Efficient Link 2</field>
+ <field name="link">http://www.example.com</field>
+ <field name="slide_id" ref="slide_slide_demo_1_2"/>
+ </record>
<record id="slide_slide_demo_1_3" model="slide.slide">
<field name="name">How to plant a potted tree</field>
+ <field name="sequence">4</field>
<field name="url">https://www.youtube.com/watch?v=QYmgrw0PgLU</field>
<field name="image" type="base64" file="website_slides/static/src/img/slide_demo_thumb_QYmgrw0PgLU.jpg"/>
<field name="document_id">QYmgrw0PgLU</field>
@@ -68,11 +99,13 @@
<field name="website_published" eval="True"/>
<field name="is_preview" eval="False"/>
<field name="public_views">0</field>
+ <field name="completion_time">1</field>
<field name="tag_ids" eval="[(4, ref('website_slides.slide_tag_demo_howto'))]"/>
<field name="description">Jim and Todd plant a potted tree for a customer of Knecht's Nurseries and Landscaping. Narrated by Leif Knecht, owner.</field>
</record>
<record id="slide_slide_demo_1_4" model="slide.slide">
<field name="name">A little chat with Harry Potted</field>
+ <field name="sequence">5</field>
<field name="image" type="base64" file="website_slides/static/src/img/slide_demo_tree_img_1.jpg"/>
<field name="slide_type">webpage</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_1_gard1"/>
@@ -99,7 +132,7 @@
<div class="container">
<div class="row align-items-center">
<div class="col-lg-6 pt16 pb16">
- <img src="/website_slides/static/src/img/slide_demo_tree_img_2.jpg" class="img img-fluid mx-auto" alt="Odoo • Image and Text"/>
+ <img src="/website_slides/static/src/img/slide_demo_tree_img_1.jpg" class="img img-fluid mx-auto" alt="Odoo • Image and Text"/>
</div>
<div class="col-lg-6 pt16 pb16">
<h2 class="o_default_snippet_text">Section Subtitle</h2>
@@ -113,13 +146,58 @@
</div>
</section></field>
<field name="website_published" eval="True"/>
- <field name="is_preview" eval="False"/>
+ <field name="is_preview" eval="True"/>
<field name="public_views">5</field>
- <field name="tag_ids" eval="[(4, ref('website_slides.slide_tag_demo_howto'))]"/>
+ <field name="completion_time">1</field>
+ <field name="tag_ids" eval="[(4, ref('website_slides.slide_tag_demo_colorful'))]"/>
<field name="description">We had a little chat with Harry Potted, sure he had interesting things to say !</field>
</record>
+ <record id="slide_slide_demo_1_4_link_0" model="slide.slide.link">
+ <field name="name">Know More Link 1</field>
+ <field name="link">http://www.example.com</field>
+ <field name="slide_id" ref="slide_slide_demo_1_4"/>
+ </record>
+ <record id="slide_slide_demo_1_4_link_1" model="slide.slide.link">
+ <field name="name">Know More Link 2</field>
+ <field name="link">http://www.example.com</field>
+ <field name="slide_id" ref="slide_slide_demo_1_4"/>
+ </record>
+ <record id="slide_slide_demo_1_4_question_0" model="slide.question">
+ <field name="question">Do you think Harry Potted has a good name ?</field>
+ <field name="slide_id" ref="slide_slide_demo_1_4"/>
+ </record>
+ <record id="slide_slide_demo_1_4_question_0_0" model="slide.answer">
+ <field name="text_value">Yes</field>
+ <field name="is_correct" eval="True"/>
+ <field name="question_id" ref="slide_slide_demo_1_4_question_0"/>
+ </record>
+ <record id="slide_slide_demo_1_4_question_0_1" model="slide.answer">
+ <field name="text_value">No</field>
+ <field name="is_correct" eval="False"/>
+ <field name="question_id" ref="slide_slide_demo_1_4_question_0"/>
+ </record>
+ <record id="slide_slide_demo_1_4_question_1" model="slide.question">
+ <field name="question">Did you read the whole article ?</field>
+ <field name="slide_id" ref="slide_slide_demo_1_4"/>
+ </record>
+ <record id="slide_slide_demo_1_4_question_1_0" model="slide.answer">
+ <field name="text_value">Yes</field>
+ <field name="is_correct" eval="True"/>
+ <field name="question_id" ref="slide_slide_demo_1_4_question_1"/>
+ </record>
+ <record id="slide_slide_demo_1_4_question_1_1" model="slide.answer">
+ <field name="text_value">No</field>
+ <field name="is_correct" eval="False"/>
+ <field name="question_id" ref="slide_slide_demo_1_4_question_1"/>
+ </record>
+ <record id="slide_slide_demo_1_4_question_1_2" model="slide.answer">
+ <field name="text_value">What was the question again ?</field>
+ <field name="is_correct" eval="False"/>
+ <field name="question_id" ref="slide_slide_demo_1_4_question_1"/>
+ </record>
<record id="slide_slide_demo_1_5" model="slide.slide">
<field name="name">3 Main Methodologies</field>
+ <field name="sequence">6</field>
<field name="datas" type="base64" file="website_slides/static/src/img/presentation.pdf"/>
<field name="image" type="base64" file="website_slides/static/src/img/channel-training-default.jpg"/>
<field name="slide_type">presentation</field>
@@ -127,7 +205,8 @@
<field name="category_id" ref="website_slides.slide_category_demo_1_1"/>
<field name="website_published" eval="True"/>
<field name="is_preview" eval="False"/>
- <field name="public_views">20</field>
+ <field name="public_views">10</field>
+ <field name="completion_time">2.5</field>
<field name="tag_ids" eval="[(4, ref('website_slides.slide_tag_demo_howto'))]"/>
<field name="description">A summary of know-how: how and what.</field>
</record>
@@ -136,26 +215,76 @@
<!-- ================================================== -->
<record id="slide_slide_demo_2_0" model="slide.slide">
<field name="name">Main Trees Categories</field>
+ <field name="sequence">1</field>
<field name="datas" type="base64" file="website_slides/static/src/img/presentation.pdf"/>
- <field name="image" type="base64" file="website_slides/static/src/img/slide_demo_tree_img_1.jpg"/>
+ <field name="image" type="base64" file="website_slides/static/src/img/slide_demo_tree_img_2.jpg"/>
<field name="slide_type">presentation</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_2_gard2"/>
<field name="category_id" ref="website_slides.slide_category_demo_2_0"/>
<field name="website_published" eval="True"/>
- <field name="is_preview" eval="False"/>
+ <field name="is_preview" eval="True"/>
<field name="public_views">0</field>
+ <field name="completion_time">1</field>
<field name="tag_ids" eval="[(4, ref('website_slides.slide_tag_demo_howto'))]"/>
+ <field name="quiz_first_attempt_reward">100</field>
+ <field name="quiz_second_attempt_reward">75</field>
+ <field name="quiz_third_attempt_reward">50</field>
+ <field name="quiz_fourth_attempt_reward">25</field>
<field name="description">A summary of know-how: how and what.</field>
</record>
+ <record id="slide_slide_demo_2_0_link_0" model="slide.slide.link">
+ <field name="name">Trees Classification Link 1</field>
+ <field name="link">http://www.example.com</field>
+ <field name="slide_id" ref="slide_slide_demo_2_0"/>
+ </record>
+ <record id="slide_slide_demo_2_0_link_1" model="slide.slide.link">
+ <field name="name">Trees Classification Link 2</field>
+ <field name="link">http://www.example.com</field>
+ <field name="slide_id" ref="slide_slide_demo_2_0"/>
+ </record>
+ <record id="slide_slide_demo_2_0_question_0" model="slide.question">
+ <field name="question">Do you make beams out of lemon trees ?</field>
+ <field name="slide_id" ref="slide_slide_demo_2_0"/>
+ </record>
+ <record id="slide_slide_demo_2_0_question_0_0" model="slide.answer">
+ <field name="text_value">Yes</field>
+ <field name="is_correct" eval="False"/>
+ <field name="question_id" ref="slide_slide_demo_2_0_question_0"/>
+ </record>
+ <record id="slide_slide_demo_2_0_question_0_1" model="slide.answer">
+ <field name="text_value">No</field>
+ <field name="is_correct" eval="True"/>
+ <field name="question_id" ref="slide_slide_demo_2_0_question_0"/>
+ </record>
+ <record id="slide_slide_demo_2_0_question_1" model="slide.question">
+ <field name="question">Do you make lemons out of beams ?</field>
+ <field name="slide_id" ref="slide_slide_demo_2_0"/>
+ </record>
+ <record id="slide_slide_demo_2_0_question_1_0" model="slide.answer">
+ <field name="text_value">Yes</field>
+ <field name="is_correct" eval="False"/>
+ <field name="question_id" ref="slide_slide_demo_2_0_question_1"/>
+ </record>
+ <record id="slide_slide_demo_2_0_question_1_1" model="slide.answer">
+ <field name="text_value">No</field>
+ <field name="is_correct" eval="True"/>
+ <field name="question_id" ref="slide_slide_demo_2_0_question_1"/>
+ </record>
+ <record id="slide_slide_demo_2_0_question_1_2" model="slide.answer">
+ <field name="text_value">And also bananas</field>
+ <field name="is_correct" eval="False"/>
+ <field name="question_id" ref="slide_slide_demo_2_0_question_1"/>
+ </record>
<record id="slide_slide_demo_2_1" model="slide.slide">
<field name="name">A Mighty Forest from Ages</field>
- <field name="image" type="base64" file="website_slides/static/src/img/slide_demo_tree_img_1.jpg"/>
+ <field name="sequence">2</field>
+ <field name="image" type="base64" file="website_slides/static/src/img/slide_demo_tree_img_3.jpg"/>
<field name="slide_type">webpage</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_2_gard2"/>
<field name="category_id" ref="website_slides.slide_category_demo_2_0"/>
<field name="html_content" type="html">
<section class="s_cover parallax bg-black-50 pt16 pb16" data-scroll-background-ratio="0" style="background-image: none;">
- <span class="s_parallax_bg oe_img_bg oe_custom_bg" style="background-image: url('/website_slides/static/src/img/slide_demo_tree_img_1.jpg'); background-position: 50% 0;"></span>
+ <span class="s_parallax_bg oe_img_bg oe_custom_bg" style="background-image: url('/website_slides/static/src/img/slide_demo_tree_img_3.jpg'); background-position: 50% 0;"></span>
<div class="container">
<div class="row s_nb_column_fixed">
<div class="col-lg-12 s_title">
@@ -175,7 +304,7 @@
<div class="container">
<div class="row align-items-center">
<div class="col-lg-6 pt16 pb16">
- <img src="/website_slides/static/src/img/slide_demo_tree_img_2.jpg" class="img img-fluid mx-auto" alt="Odoo • Image and Text"/>
+ <img src="/website_slides/static/src/img/slide_demo_tree_img_3.jpg" class="img img-fluid mx-auto" alt="Odoo • Image and Text"/>
</div>
<div class="col-lg-6 pt16 pb16">
<h2 class="o_default_snippet_text">Section Subtitle</h2>
@@ -191,35 +320,114 @@
<field name="website_published" eval="True"/>
<field name="is_preview" eval="False"/>
<field name="public_views">0</field>
+ <field name="completion_time">2</field>
<field name="tag_ids" eval="[(4, ref('website_slides.slide_tag_demo_theory'))]"/>
<field name="description">Mighty forest just don't appear in a few weeks. Learn how time made our forests mighty and mysterious.</field>
</record>
+ <record id="slide_slide_demo_2_2" model="slide.slide">
+ <field name="name">Tree planting in hanging bottles on wall</field>
+ <field name="url">https://www.youtube.com/watch?v=ebBez6bcSEc</field>
+ <field name="image" type="base64" file="website_slides/static/src/img/slide_demo_thumb_ebBez6bcSEc.jpg"/>
+ <field name="document_id">ebBez6bcSEc</field>
+ <field name="slide_type">video</field>
+ <field name="channel_id" ref="website_slides.slide_channel_demo_2_gard2"/>
+ <field name="category_id" ref="website_slides.slide_category_demo_2_0"/>
+ <field name="website_published" eval="True"/>
+ <field name="is_preview" eval="False"/>
+ <field name="public_views">0</field>
+ <field name="completion_time">1</field>
+ <field name="tag_ids" eval="[(4, ref('website_slides.slide_tag_demo_howto'))]"/>
+ <field name="description">How to wall decorating by tree planting in hanging plastic bottles.</field>
+ </record>
- <!-- CHANNEL 2: Trees, Wood and Garden -->
- <!-- ================================================== -->
+ <!-- CHANNEL 5: Basics of Furniture Creation -->
+ <!-- ======================================= -->
<record id="slide_slide_demo_5_0" model="slide.slide">
+ <field name="name">Unforgettable Tools</field>
+ <field name="sequence">1</field>
+ <field name="datas" type="base64" file="website_slides/static/src/img/presentation.pdf"/>
+ <field name="image" type="base64" file="website_slides/static/src/img/channel-training-default.jpg"/>
+ <field name="slide_type">presentation</field>
+ <field name="channel_id" ref="website_slides.slide_channel_demo_5_furn2"/>
+ <field name="category_id" ref="website_slides.slide_category_demo_5_0"/>
+ <field name="website_published" eval="True"/>
+ <field name="is_preview" eval="True"/>
+ <field name="public_views">10</field>
+ <field name="completion_time">2.5</field>
+ <field name="tag_ids" eval="[(4, ref('website_slides.slide_tag_demo_tools'))]"/>
+ <field name="description">Tools you will need to complete this course.</field>
+ </record>
+ <record id="slide_slide_demo_5_0_link_0" model="slide.slide.link">
+ <field name="name">Example Link 1</field>
+ <field name="link">http://www.example.com</field>
+ <field name="slide_id" ref="slide_slide_demo_5_0"/>
+ </record>
+ <record id="slide_slide_demo_5_0_link_1" model="slide.slide.link">
+ <field name="name">Example Link 2</field>
+ <field name="link">http://www.example.com</field>
+ <field name="slide_id" ref="slide_slide_demo_5_0"/>
+ </record>
+ <record id="slide_slide_demo_5_1" model="slide.slide">
<field name="name">How to find quality wood</field>
+ <field name="sequence">2</field>
+ <field name="image" type="base64" file="website_slides/static/src/img/slide_demo_thumb_5WMqwTnZ-qs.jpg"/>
<field name="url">https://www.youtube.com/watch?v=5WMqwTnZ-qs</field>
<field name="document_id">5WMqwTnZ-qs</field>
<field name="slide_type">video</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_5_furn2"/>
- <field name="category_id" ref="website_slides.slide_category_demo_5_0"/>
+ <field name="category_id" ref="website_slides.slide_category_demo_5_1"/>
+ <field name="category_id" eval="False"/>
<field name="website_published" eval="True"/>
<field name="is_preview" eval="False"/>
- <field name="image" type="base64" file="website_slides/static/src/img/slide_demo_thumb_5WMqwTnZ-qs.jpg"/>
+ <field name="public_views">0</field>
+ <field name="completion_time">1</field>
<field name="description">Learn of to identify quality wood in order to create solid furnitures.</field>
</record>
- <record id="slide_slide_demo_5_1" model="slide.slide">
+ <record id="slide_slide_demo_5_2" model="slide.slide">
<field name="name">How to create your own piece of furniture</field>
+ <field name="sequence">3</field>
+ <field name="image" type="base64" file="website_slides/static/src/img/slide_demo_thumb_ptjeDDoURL8.jpg"/>
<field name="url">https://www.youtube.com/watch?v=ptjeDDoURL8</field>
<field name="document_id">ptjeDDoURL8</field>
<field name="slide_type">video</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_5_furn2"/>
- <field name="category_id" ref="website_slides.slide_category_demo_5_0"/>
+ <field name="category_id" ref="website_slides.slide_category_demo_5_1"/>
+ <field name="category_id" eval="False"/>
<field name="website_published" eval="True"/>
<field name="is_preview" eval="True"/>
- <field name="image" type="base64" file="website_slides/static/src/img/slide_demo_thumb_ptjeDDoURL8.jpg"/>
+ <field name="public_views">5</field>
+ <field name="completion_time">3</field>
<field name="description">From a piece of wood to a fully functional furniture, step by step.</field>
</record>
+ <record id="slide_slide_demo_5_3" model="slide.slide">
+ <field name="name">Test your knowledge !</field>
+ <field name="sequence">4</field>
+ <field name="image" type="base64" file="website_slides/static/src/img/slide_demo_owl.jpg"/>
+ <field name="slide_type">quiz</field>
+ <field name="channel_id" ref="website_slides.slide_channel_demo_5_furn2"/>
+ <field name="category_id" ref="website_slides.slide_category_demo_5_0"/>
+ <field name="website_published" eval="True"/>
+ <field name="is_preview" eval="False"/>
+ <field name="public_views">0</field>
+ <field name="completion_time">0.5</field>
+ <field name="description">Test your knowledge !</field>
+ </record>
+ <record id="slide_slide_demo_5_3_question_0" model="slide.question">
+ <field name="question">Do you want to reply correctly ?</field>
+ <field name="slide_id" ref="slide_slide_demo_5_3"/>
+ </record>
+ <record id="slide_slide_demo_5_3_question_0_0" model="slide.answer">
+ <field name="text_value">Yes</field>
+ <field name="is_correct" eval="True"/>
+ <field name="question_id" ref="slide_slide_demo_5_3_question_0"/>
+ </record>
+ <record id="slide_slide_demo_5_3_question_0_1" model="slide.answer">
+ <field name="text_value">No</field>
+ <field name="is_correct" eval="False"/>
+ <field name="question_id" ref="slide_slide_demo_5_3_question_0"/>
+ </record>
+
+ <!-- CHANNEL 6: DIY Furniture -->
+ <!-- ======================================= -->
</data></odoo>
diff --git a/addons/website_slides/data/slide_user_demo.xml b/addons/website_slides/data/slide_user_demo.xml
index aa08a5b1755..3254205e18a 100644
--- a/addons/website_slides/data/slide_user_demo.xml
+++ b/addons/website_slides/data/slide_user_demo.xml
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
-<odoo><data noupdate="0">
+<odoo><data noupdate="1">
<!-- CHANNEL 0: Basics of Gardening -->
<!-- ================================================== -->
@@ -11,6 +11,13 @@
<field name="slide_id" ref="website_slides.slide_slide_demo_0_0"/>
<field name="partner_id" ref="base.partner_admin"/>
<field name="completed" eval="True"/>
+ <field name="vote">1</field>
+ </record>
+ <record id="slide_slide_0_1_partner_admin" model="slide.slide.partner">
+ <field name="slide_id" ref="website_slides.slide_slide_demo_0_1"/>
+ <field name="partner_id" ref="base.partner_admin"/>
+ <field name="completed" eval="True"/>
+ <field name="vote">1</field>
</record>
<record id="slide_channel_0_partner_demo" model="slide.channel.partner">
<field name="channel_id" ref="website_slides.channel_public"/>
@@ -19,6 +26,21 @@
<record id="slide_slide_0_0_partner_demo" model="slide.slide.partner">
<field name="slide_id" ref="website_slides.slide_slide_demo_0_0"/>
<field name="partner_id" ref="base.partner_demo"/>
+ <field name="vote">1</field>
+ </record>
+ <record id="slide_slide_0_1_partner_demo" model="slide.slide.partner">
+ <field name="slide_id" ref="website_slides.slide_slide_demo_0_1"/>
+ <field name="partner_id" ref="base.partner_demo"/>
+ <field name="vote">1</field>
+ </record>
+ <record id="slide_channel_0_partner_demo_portal" model="slide.channel.partner">
+ <field name="channel_id" ref="website_slides.channel_public"/>
+ <field name="partner_id" ref="base.partner_demo_portal"/>
+ </record>
+ <record id="slide_slide_0_0_partner_demo_portal" model="slide.slide.partner">
+ <field name="slide_id" ref="website_slides.slide_slide_demo_0_0"/>
+ <field name="partner_id" ref="base.partner_demo_portal"/>
+ <field name="vote">1</field>
</record>
@@ -38,24 +60,68 @@
<field name="partner_id" ref="base.partner_admin"/>
<field name="completed" eval="True"/>
</record>
+ <record id="slide_slide_1_2_partner_admin" model="slide.slide.partner">
+ <field name="slide_id" ref="website_slides.slide_slide_demo_1_2"/>
+ <field name="partner_id" ref="base.partner_admin"/>
+ <field name="vote">1</field>
+ <field name="completed" eval="True"/>
+ </record>
+ <record id="slide_slide_1_3_partner_admin" model="slide.slide.partner">
+ <field name="slide_id" ref="website_slides.slide_slide_demo_1_3"/>
+ <field name="partner_id" ref="base.partner_admin"/>
+ <field name="vote">1</field>
+ <field name="completed" eval="True"/>
+ </record>
+ <record id="slide_channel_1_partner_demo" model="slide.channel.partner">
+ <field name="channel_id" ref="website_slides.slide_channel_demo_1_gard1"/>
+ <field name="partner_id" ref="base.partner_demo"/>
+ </record>
+ <record id="slide_slide_1_0_partner_demo" model="slide.slide.partner">
+ <field name="slide_id" ref="website_slides.slide_slide_demo_1_0"/>
+ <field name="partner_id" ref="base.partner_demo"/>
+ <field name="vote">1</field>
+ <field name="completed" eval="True"/>
+ </record>
+ <record id="slide_slide_1_1_partner_demo" model="slide.slide.partner">
+ <field name="slide_id" ref="website_slides.slide_slide_demo_1_1"/>
+ <field name="partner_id" ref="base.partner_demo"/>
+ <field name="vote">1</field>
+ </record>
+ <record id="slide_channel_1_partner_demo_portal" model="slide.channel.partner">
+ <field name="channel_id" ref="website_slides.slide_channel_demo_1_gard1"/>
+ <field name="partner_id" ref="base.partner_demo_portal"/>
+ <field name="completed" eval="True"/>
+ </record>
+ <record id="slide_slide_1_0_partner_demo" model="slide.slide.partner">
+ <field name="slide_id" ref="website_slides.slide_slide_demo_1_0"/>
+ <field name="partner_id" ref="base.partner_demo_portal"/>
+ <field name="vote">1</field>
+ <field name="completed" eval="True"/>
+ </record>
- <record id="message_channel_1_root" model="mail.message">
+ <record id="message_channel_1_admin" model="mail.message">
<field name="model">slide.channel</field>
<field name="res_id" ref="website_slides.slide_channel_demo_1_gard1"/>
- <field name="author_id" ref="base.partner_root"/>
+ <field name="subtype_id" ref="mail.mt_comment"/>
+ <field name="website_published" eval="True"/>
+ <field name="message_type">comment</field>
+ <field name="author_id" ref="base.partner_admin"/>
<field name="body" type="html"><div>Very good course.</div></field>
</record>
<record id="rating_channel_1_root" model="rating.rating">
<field name="res_model_id" ref="website_slides.model_slide_channel"/>
<field name="res_id" ref="website_slides.slide_channel_demo_1_gard1"/>
- <field name="partner_id" ref="base.partner_root"/>
+ <field name="partner_id" ref="base.partner_admin"/>
<field name="consumed" eval="True"/>
- <field name="rating">4</field>
- <field name="message_id" ref="website_slides.message_channel_1_root"/>
+ <field name="rating">10</field>
+ <field name="message_id" ref="website_slides.message_channel_1_admin"/>
</record>
<record id="message_channel_1_demo" model="mail.message">
<field name="model">slide.channel</field>
<field name="res_id" ref="website_slides.slide_channel_demo_1_gard1"/>
+ <field name="subtype_id" ref="mail.mt_comment"/>
+ <field name="website_published" eval="True"/>
+ <field name="message_type">comment</field>
<field name="author_id" ref="base.partner_demo"/>
<field name="body" type="html"><div>Interesting !</div></field>
</record>
@@ -64,24 +130,28 @@
<field name="res_id" ref="website_slides.slide_channel_demo_1_gard1"/>
<field name="partner_id" ref="base.partner_demo"/>
<field name="consumed" eval="True"/>
- <field name="rating">4</field>
+ <field name="rating">8</field>
<field name="message_id" ref="website_slides.message_channel_1_demo"/>
</record>
<record id="message_channel_1_portal" model="mail.message">
<field name="model">slide.channel</field>
<field name="res_id" ref="website_slides.slide_channel_demo_1_gard1"/>
+ <field name="subtype_id" ref="mail.mt_comment"/>
+ <field name="website_published" eval="True"/>
+ <field name="message_type">comment</field>
<field name="author_id" ref="base.partner_demo_portal"/>
- <field name="body" type="html"><div>Missing examples.</div></field>
+ <field name="body" type="html"><div>Interesting. Could be great to include more examples.</div></field>
</record>
<record id="rating_channel_1_portal" model="rating.rating">
<field name="res_model_id" ref="website_slides.model_slide_channel"/>
<field name="res_id" ref="website_slides.slide_channel_demo_1_gard1"/>
<field name="partner_id" ref="base.partner_demo_portal"/>
<field name="consumed" eval="True"/>
- <field name="rating">3</field>
- <field name="message_id" ref="website_slides.message_channel_1_demo"/>
+ <field name="rating">6</field>
+ <field name="message_id" ref="website_slides.message_channel_1_portal"/>
</record>
+
<!-- CHANNEL 2: Trees, Wood and Garden -->
<!-- ================================================== -->
<record id="slide_channel_2_partner_admin" model="slide.channel.partner">
@@ -92,7 +162,20 @@
<field name="slide_id" ref="website_slides.slide_slide_demo_2_0"/>
<field name="partner_id" ref="base.partner_admin"/>
<field name="vote">1</field>
- <field name="completed" eval="True"/>
+ </record>
+ <record id="slide_slide_2_1_partner_admin" model="slide.slide.partner">
+ <field name="slide_id" ref="website_slides.slide_slide_demo_2_1"/>
+ <field name="partner_id" ref="base.partner_admin"/>
+ <field name="vote">1</field>
+ </record>
+ <record id="slide_channel_2_partner_demo" model="slide.channel.partner">
+ <field name="channel_id" ref="website_slides.slide_channel_demo_2_gard2"/>
+ <field name="partner_id" ref="base.partner_demo"/>
+ </record>
+ <record id="slide_slide_2_0_partner_demo" model="slide.slide.partner">
+ <field name="slide_id" ref="website_slides.slide_slide_demo_2_0"/>
+ <field name="partner_id" ref="base.partner_demo"/>
+ <field name="vote">1</field>
</record>
</data></odoo>
diff --git a/addons/website_slides/models/slide_channel.py b/addons/website_slides/models/slide_channel.py
index 30aee951c11..8f5e2918c0c 100644
--- a/addons/website_slides/models/slide_channel.py
+++ b/addons/website_slides/models/slide_channel.py
@@ -280,8 +280,8 @@ class Channel(models.Model):
@api.model
def create(self, vals):
- # Ensure creator is member of its channel it is easier for him to manage it
- if not vals.get('channel_partner_ids'):
+ # Ensure creator is member of its channel it is easier for him to manage it (unless it is odoobot)
+ if not vals.get('channel_partner_ids') and not self.env.user._is_superuser():
vals['channel_partner_ids'] = [(0, 0, {
'partner_id': self.env.user.partner_id.id
})]
@@ -418,6 +418,41 @@ class Channel(models.Model):
domain = super(Channel, self)._rating_domain()
return expression.AND([domain, [('website_published', '=', True)]])
+ # ---------------------------------------------------------
+ # Data / Misc
+ # ---------------------------------------------------------
+
+ def _get_categorized_slides(self, base_domain, order, force_void=True, limit=False, offset=False):
+ """ Return an ordered structure of slides by categories within a given
+ base_domain that must fulfill slides. """
+ self.ensure_one()
+ all_categories = self.env['slide.category'].search([('channel_id', '=', self.id)])
+ all_slides = self.env['slide.slide'].sudo().search(base_domain, order=order)
+ category_data = []
+
+ # First add uncategorized slides
+ uncategorized_slides = all_slides.filtered(lambda slide: not slide.category_id)
+ if uncategorized_slides or force_void:
+ category_data.append({
+ 'category': False, 'id': False,
+ 'name': _('Uncategorized'), 'slug_name': _('Uncategorized'),
+ 'total_slides': len(uncategorized_slides),
+ 'slides': uncategorized_slides[(offset or 0):(limit or len(uncategorized_slides))],
+ })
+ # Then all categories by natural order
+ for category in all_categories:
+ category_slides = all_slides.filtered(lambda slide: slide.category_id == category)
+ if not category_slides and not force_void:
+ continue
+ category_data.append({
+ 'category': category, 'id': category.id,
+ 'name': category.name, 'slug_name': slug(category),
+ 'total_slides': len(category_slides),
+ 'slides': category_slides[(offset or 0):(limit or len(category_slides))],
+ })
+
+ return category_data
+
class Category(models.Model):
""" Channel contain various categories to manage its slides """
diff --git a/addons/website_slides/models/slide_slide.py b/addons/website_slides/models/slide_slide.py
index 9ca2eff8adb..53378df947c 100644
--- a/addons/website_slides/models/slide_slide.py
+++ b/addons/website_slides/models/slide_slide.py
@@ -24,21 +24,22 @@ class SlidePartnerRelation(models.Model):
_table = 'slide_slide_partner'
slide_id = fields.Many2one('slide.slide', ondelete="cascade", index=True, required=True)
- channel_id = fields.Many2one('slide.channel', string="Channel", related="slide_id.channel_id", store=True, index=True)
- partner_id = fields.Many2one('res.partner', index=True, required=True)
+ channel_id = fields.Many2one(
+ 'slide.channel', string="Channel",
+ related="slide_id.channel_id", store=True, index=True, ondelete='cascade')
+ partner_id = fields.Many2one('res.partner', index=True, required=True, ondelete='cascade')
vote = fields.Integer('Vote', default=0)
completed = fields.Boolean('Completed')
-
- quiz_attempts_count = fields.Integer(string="Number of times this user attempted the quiz")
+ quiz_attempts_count = fields.Integer('Quiz attempts count', default=0)
class SlideLink(models.Model):
_name = 'slide.slide.link'
_description = "External URL for a particular slide"
- slide_id = fields.Many2one('slide.slide', required=True)
+ slide_id = fields.Many2one('slide.slide', required=True, ondelete='cascade')
name = fields.Char('Title', required=True)
- link = fields.Char("External Link", required=True)
+ link = fields.Char('External Link', required=True)
class EmbeddedSlide(models.Model):
@@ -51,8 +52,10 @@ class EmbeddedSlide(models.Model):
url = fields.Char('Third Party Website URL', required=True)
count_views = fields.Integer('# Views', default=1)
- def add_embed_url(self, slide_id, url):
+ def _add_embed_url(self, slide_id, url):
baseurl = urls.url_parse(url).netloc
+ if not baseurl:
+ return 0
embeds = self.search([('url', '=', baseurl), ('slide_id', '=', int(slide_id))], limit=1)
if embeds:
embeds.count_views += 1
@@ -85,6 +88,7 @@ class Slide(models.Model):
_description = 'Slides'
_mail_post_access = 'read'
_order_by_strategy = {
+ 'sequence': 'category_sequence asc, sequence asc',
'most_viewed': 'total_views desc',
'most_voted': 'likes desc',
'latest': 'date_published desc',
@@ -338,22 +342,6 @@ class Slide(models.Model):
# Business Methods
# ---------------------------------------------------------
- def _get_related_slides(self, limit=20):
- # TDE FIXME: should build a smarter domain
- domain = request.website.website_domain()
- domain += [('website_published', '=', True), ('id', '!=', self.id)]
- if self.category_id:
- domain += [('category_id', '=', self.category_id.id)]
- for record in self.search(domain, limit=limit):
- yield record
-
- def _get_most_viewed_slides(self, limit=20):
- # TDE FIXME: should build a smarter domain
- domain = request.website.website_domain()
- domain += [('website_published', '=', True), ('id', '!=', self.id)]
- for record in self.search(domain, limit=limit, order='total_views desc'):
- yield record
-
def _post_publication(self):
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
for slide in self.filtered(lambda slide: slide.website_published and slide.channel_id.publish_template_id):
@@ -423,52 +411,113 @@ class Slide(models.Model):
})
self.env.user.add_karma(new_slide.channel_id.karma_gen_slide_vote)
- def action_set_viewed(self):
+ def action_set_viewed(self, quiz_attempts_inc=False):
if not all(slide.channel_id.is_member for slide in self):
raise UserError(_('You cannot mark a slide as viewed if you are not among its members.'))
- return bool(self._action_set_viewed(self.env.user.partner_id))
+ return bool(self._action_set_viewed(self.env.user.partner_id, quiz_attempts_inc=quiz_attempts_inc))
- def _action_set_viewed(self, target_partner):
+ def _action_set_viewed(self, target_partner, quiz_attempts_inc=False):
self_sudo = self.sudo()
SlidePartnerSudo = self.env['slide.slide.partner'].sudo()
existing_sudo = SlidePartnerSudo.search([
('slide_id', 'in', self.ids),
('partner_id', '=', target_partner.id)
])
+ if quiz_attempts_inc:
+ for exsting_slide in existing_sudo:
+ exsting_slide.write({
+ 'quiz_attempts_count': exsting_slide.quiz_attempts_count + 1
+ })
new_slides = self_sudo - existing_sudo.mapped('slide_id')
return SlidePartnerSudo.create([{
'slide_id': new_slide.id,
'channel_id': new_slide.channel_id.id,
'partner_id': target_partner.id,
+ 'quiz_attempts_count': 1 if quiz_attempts_inc else 0,
'vote': 0} for new_slide in new_slides])
- def action_set_completed(self):
+ def action_set_completed(self, quiz_attempts_inc=False):
if not all(slide.channel_id.is_member for slide in self):
raise UserError(_('You cannot mark a slide as completed if you are not among its members.'))
- return self._action_set_completed(self.env.user.partner_id)
+ return self._action_set_completed(self.env.user.partner_id, quiz_attempts_inc=quiz_attempts_inc)
- def _action_set_completed(self, target_partner):
+ def _action_set_completed(self, target_partner, quiz_attempts_inc=False):
self_sudo = self.sudo()
SlidePartnerSudo = self.env['slide.slide.partner'].sudo()
existing_sudo = SlidePartnerSudo.search([
('slide_id', 'in', self.ids),
('partner_id', '=', target_partner.id)
])
- existing_sudo.write({'completed': True})
+ if quiz_attempts_inc:
+ for existing_slide in existing_sudo:
+ existing_slide.write({
+ 'completed': True,
+ 'quiz_attempts_count': existing_slide.quiz_attempts_count + 1
+ })
+ else:
+ existing_sudo.write({'completed': True})
new_slides = self_sudo - existing_sudo.mapped('slide_id')
SlidePartnerSudo.create([{
'slide_id': new_slide.id,
'channel_id': new_slide.channel_id.id,
'partner_id': target_partner.id,
+ 'quiz_attempts_count': 1 if quiz_attempts_inc else 0,
'vote': 0,
'completed': True} for new_slide in new_slides])
return True
+ def _action_set_quiz_done(self):
+ if not all(slide.channel_id.is_member for slide in self):
+ raise UserError(_('You cannot mark a slide quiz as completed if you are not among its members.'))
+
+ points = 0
+ for slide in self:
+ if not slide.user_membership_id or slide.user_membership_id.completed or not slide.user_membership_id.quiz_attempts_count:
+ continue
+
+ gains = [slide.quiz_first_attempt_reward,
+ slide.quiz_second_attempt_reward,
+ slide.quiz_third_attempt_reward,
+ slide.quiz_fourth_attempt_reward]
+ points += gains[slide.user_membership_id.quiz_attempts_count-1] if slide.user_membership_id.quiz_attempts_count <= len(gains) else gains[-1]
+
+ return self.env.user.sudo().add_karma(points)
+
+ def _compute_quiz_info(self, target_partner, quiz_done=False):
+ result = dict.fromkeys(self.ids, False)
+ slide_partners = self.env['slide.slide.partner'].sudo().search([
+ ('slide_id', 'in', self.ids),
+ ('partner_id', '=', target_partner.id)
+ ])
+ slide_partners_map = dict((sp.slide_id.id, sp) for sp in slide_partners)
+ for slide in self:
+ if not slide.question_ids:
+ gains = [0]
+ else:
+ gains = [slide.quiz_first_attempt_reward,
+ slide.quiz_second_attempt_reward,
+ slide.quiz_third_attempt_reward,
+ slide.quiz_fourth_attempt_reward]
+ result[slide.id] = {
+ 'quiz_karma_max': gains[0], # what could be gained if succeed at first try
+ 'quiz_karma_gain': gains[0], # what would be gained at next test
+ 'quiz_karma_won': 0, # what has been gained
+ 'quiz_attempts_count': 0, # number of attempts
+ }
+ slide_partner = slide_partners_map.get(slide.id)
+ if slide.question_ids and slide_partner:
+ if slide_partner.quiz_attempts_count:
+ result[slide.id]['quiz_karma_gain'] = gains[slide_partner.quiz_attempts_count] if slide_partner.quiz_attempts_count <= len(gains) else gains[-1]
+ result[slide.id]['quiz_attempts_count'] = slide_partner.quiz_attempts_count
+ if quiz_done or slide_partner.completed:
+ result[slide.id]['quiz_karma_won'] = gains[slide_partner.quiz_attempts_count-1] if slide_partner.quiz_attempts_count < len(gains) else gains[-1]
+ return result
+
# --------------------------------------------------
# Parsing methods
# --------------------------------------------------
diff --git a/addons/website_slides/static/src/img/banner_heroes_default.svg b/addons/website_slides/static/src/img/banner_heroes_default.svg
new file mode 100644
index 00000000000..9f3536252c2
--- /dev/null
+++ b/addons/website_slides/static/src/img/banner_heroes_default.svg
@@ -0,0 +1 @@
+<svg height="749" viewBox="0 0 1920 749" width="1920" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><path id="a" d="m0 0h1920v749h-1920z"/><mask id="b" fill="#fff"><use fill="#fff" fill-rule="evenodd" xlink:href="#a"/></mask></defs><g fill="none" fill-rule="evenodd"><use fill="#36576b" fill-rule="nonzero" xlink:href="#a"/><path d="m3341 938.466-1537.519 41.694 1516.347-263.036-27.493-142.331-1507.085 346.379 1442.362-564.899-54.347-134.029-1404.837 639.492 1302.345-835.712-78.722-120.744-1262.595 909.092 1120.945-1085.68-100.459-102.581-1058.316 1139.946 890.125-1281.175-117.982-80.781-825.865 1333.387 628.834-1441.164-131.231-55.518-550.657 1466.828 342.352-1531.557-139.269-28.519-263.413 1554.707 41.001-1577.265h-142.042l40.832 1570.374-257.534-1548.753-139.351 28.08 339.119 1539.321-553.052-1473.216-131.227 55.518 626.107 1434.912-818.215-1330.23-118.218 80.418 890.052 1289.592-1062.949-1144.916-100.44 102.588 1116.053 1080.944-1254.306-909.138-79.103 120.495 1305.438 843.526-1410.953-642.275-54.355 134.033 1436.113 562.432-1499.485-349.672-27.928 142.252 1522.219 269.069-1544.296-41.888v145.061l1537.537-41.694-1516.372 263.046 27.497 142.318 1507.106-346.373-1442.391 564.896 54.355 134.033 1404.855-639.493-1302.36 835.709 78.722 120.758 1262.602-909.096-1120.948 1085.674 100.433 102.584 1058.334-1139.935-890.128 1281.157 117.978 80.795 825.897-1333.43-628.866 1441.2 131.231 55.525 550.668-1466.845-342.356 1531.564 139.266 28.519 263.427-1554.697-41.008 1577.258h142.042l-40.825-1570.407 257.53 1548.783 139.348-28.081-339.116-1539.317 553.049 1473.223 131.231-55.525-626.097-1434.887 818.204 1330.202 118.233-80.415-890.031-1289.567 1062.906 1144.88 100.448-102.584-1116.064-1080.941 1254.323 909.139 79.093-120.499-1305.478-843.548 1411.001 642.297 54.336-134.033-1436.084-562.443 1499.471 349.683 27.91-142.26-1522.072-269.043 1544.163 41.87z" fill="#fff" mask="url(#b)" opacity=".043"/></g></svg> \ No newline at end of file
diff --git a/addons/website_sale_slides/static/img/channel_demo_diy.jpg b/addons/website_slides/static/src/img/channel_demo_diy.jpg
index fe51b1cc6bf..fe51b1cc6bf 100644
--- a/addons/website_sale_slides/static/img/channel_demo_diy.jpg
+++ b/addons/website_slides/static/src/img/channel_demo_diy.jpg
Binary files differ
diff --git a/addons/website_slides/static/src/img/channel_demo_furniture_3.jpeg b/addons/website_slides/static/src/img/channel_demo_furniture_3.jpg
index 570120556da..570120556da 100644
--- a/addons/website_slides/static/src/img/channel_demo_furniture_3.jpeg
+++ b/addons/website_slides/static/src/img/channel_demo_furniture_3.jpg
Binary files differ
diff --git a/addons/website_slides/static/src/img/slide_demo_gardening_1.jpg b/addons/website_slides/static/src/img/slide_demo_gardening_1.jpg
new file mode 100644
index 00000000000..75dc371a53e
--- /dev/null
+++ b/addons/website_slides/static/src/img/slide_demo_gardening_1.jpg
Binary files differ
diff --git a/addons/website_slides/static/src/img/slide_demo_owl.jpg b/addons/website_slides/static/src/img/slide_demo_owl.jpg
new file mode 100644
index 00000000000..05e7ac6d975
--- /dev/null
+++ b/addons/website_slides/static/src/img/slide_demo_owl.jpg
Binary files differ
diff --git a/addons/website_slides/static/src/img/slide_demo_thumb_ebBez6bcSEc.jpg b/addons/website_slides/static/src/img/slide_demo_thumb_ebBez6bcSEc.jpg
new file mode 100644
index 00000000000..f534e522bab
--- /dev/null
+++ b/addons/website_slides/static/src/img/slide_demo_thumb_ebBez6bcSEc.jpg
Binary files differ
diff --git a/addons/website_slides/static/src/js/slides_course_fullscreen_player.js b/addons/website_slides/static/src/js/slides_course_fullscreen_player.js
index fc37eece601..d5df0540f3e 100644
--- a/addons/website_slides/static/src/js/slides_course_fullscreen_player.js
+++ b/addons/website_slides/static/src/js/slides_course_fullscreen_player.js
@@ -17,6 +17,10 @@ odoo.define('website_slides.fullscreen', function (require) {
var Fullscreen = publicWidget.Widget.extend({
+ custom_events: {
+ quiz_next_slide: '_goToNextSlide',
+ quiz_completed: '_onQuizCompleted'
+ },
/**
* @override
* @param {Object} el
@@ -36,11 +40,11 @@ odoo.define('website_slides.fullscreen', function (require) {
this.activetab = undefined;
this.player = undefined;
this.goToQuiz = false;
- this.answeredQuestions = [];
this.slideTitle = undefined;
return this._super.apply(this,arguments);
},
start: function (){
+ var self = this;
this.url = window.location.pathname;
this.urlToSmallScreen = this.url.replace('/fullscreen','');
this._getSlides();
@@ -58,46 +62,50 @@ odoo.define('website_slides.fullscreen', function (require) {
*/
_renderPlayer: function (){
var self = this;
- var embed_url;
- if (self.slide.slide_type !== 'webpage' || self.slide.htmlContent){
- if ((self.slide.slide_type === "quiz" || self.slide.has_quiz) && !self.slide.quiz){
- self._fetchQuiz();
- } else {
- embed_url = $(this.slide.embed_code).attr('src');
- if (self.slide.slide_type === "video"){
+
+ var def = new Promise(function () {});
+ if (this.slide.slide_type === 'webpage') {
+ this._fetchHtmlContent().then(function () {
+ self._renderWebpage();
+ def.resolve();
+ });
+ } else {
+ if (this.slide.slide_type !== 'quiz') {
+ // no RPC, get slide data from existing DOM
+ var embed_url = $(this.slide.embed_code).attr('src');
+ if (this.slide.slide_type === "video"){
embed_url = "https://" + embed_url + "&rel=0&autoplay=1&enablejsapi=1&origin=" + window.location.origin;
}
$('.o_wslides_fs_player').html(QWeb.render('website.slides.fullscreen', {
slide: self.slide,
nextSlide: self.nextSlide,
questions: self.slide.quiz ? self.slide.quiz.questions: '',
- reward: self.slide.quiz ? self.slide.quiz.nb_attempts < 3 ? self.slide.quiz.possible_rewards[self.slide.quiz.nb_attempts] : self.slide.quiz.possible_rewards[3]: self.slide.maxPoints,
embed_url: embed_url,
question_count: self.slide.quiz ? self.slide.quiz.questions.length : '',
letters: self.slide.quiz ? self.letters : '',
showMiniQuiz: self.goToQuiz
}));
- if (self.slide.slide_type === "video"){
- self._renderYoutubeIframe();
- }
- if (self.slide.slide_type === 'webpage'){
- self._renderWebpage();
- }
- if ((self.slide.slide_type === "presentation" || self.slide.slide_type === "document" || self.slide.slide_type === "infographic" || self.slide.slide_type === "webpage") && !self.slide.quiz){
- self._setSlideStateAsDone();
- }
- if ((self.slide.quiz && self.slide.slide_type === "quiz") || self.goToQuiz){
- self._renderQuiz();
+
+ if(this.slide.slide_type === "video"){
+ this._renderYoutubeIframe();
}
+ def.resolve();
}
- } else {
- self._fetchHtmlContent();
}
- self._renderTitle();
+
+ if (this.slide.slide_type === "quiz" || this.goToQuiz) {
+ this._renderQuiz().then(function() {
+ def.resolve();
+ });
+ }
+
+ return def.then(function() {
+ self._renderTitle();
+ });
},
_renderYoutubeIframe: function (){
var self = this;
- /**
+ /**
* Due to issues of synchronization between the youtube api script and the widget's instanciation.
*/
try {
@@ -114,18 +122,15 @@ odoo.define('website_slides.fullscreen', function (require) {
var self = this;
$(self.slide.htmlContent).appendTo('.o_wslides_fs_webpage_content');
},
+
_renderQuiz: function (){
- var self = this;
- var Quiz = new QuizWidget(this, self.slide, self.nextSlide);
- Quiz.appendTo('.o_wslides_fs_player');
- $('.next-slide').click(function (){
- self._goToNextSlide();
- });
- $('.back-to-video').click(function (){
- self.goToQuiz = false;
- self._renderPlayer();
+ var quizSlideData = _.extend(this.slide, {
+ completed: this.slide.done,
});
+ var Quiz = new QuizWidget(this, quizSlideData);
+ return Quiz.appendTo('.o_wslides_fs_player');
},
+
_renderTitle: function (){
var self = this;
$('.o_wslides_fs_slide_title').empty().html(QWeb.render('website.course.fullscreen.title', {
@@ -168,7 +173,7 @@ odoo.define('website_slides.fullscreen', function (require) {
var totalTime = event.target.getDuration();
if (totalTime && currentTime > totalTime - 30){
clearInterval(self.tid);
- if (!self.slide.has_quiz && !self.slide.done){
+ if (!self.slide.hasQuiz && !self.slide.done){
self.slide.done = true;
self._setSlideStateAsDone();
}
@@ -194,36 +199,20 @@ odoo.define('website_slides.fullscreen', function (require) {
this._getActiveSlide();
}
},
+
/**
* @private
- * @param {object} slide
- * Fetch the quiz for a particular slide
*/
- _fetchQuiz: function (){
- var self = this;
- self._rpc({
- route:"/slide/quiz/get",
- params: {
- 'slide_id': self.slide.id
- }
- }).then(function (data){
- if (data){
- self.slide.quiz = data;
- self._renderPlayer();
- }
- });
- },
- _fetchHtmlContent: function (){
+ _fetchHtmlContent: function(){
var self = this;
- self._rpc({
- route:"/slide/html_content/get",
+ return self._rpc({
+ route: 'slides/slide/get_html_content',
params: {
'slide_id': self.slide.id
}
}).then(function (data){
if (data.html_content) {
self.slide.htmlContent = data.html_content;
- self._renderPlayer();
}
});
},
@@ -234,7 +223,7 @@ odoo.define('website_slides.fullscreen', function (require) {
*/
_setSlideStateAsDone: function (){
var self = this;
- self._rpc({
+ return self._rpc({
route: '/slides/slide/set_completed',
params: {
slide_id: self.slide.id,
@@ -269,7 +258,7 @@ odoo.define('website_slides.fullscreen', function (require) {
var self = this;
clearInterval(self.tid);
self.player = undefined;
- self.goToQuiz = self.slide.has_quiz && !self.goToQuiz && self.slide.slide_type !== 'quiz';
+ self.goToQuiz = self.slide.hasQuiz && !self.goToQuiz && self.slide.slide_type !== 'quiz';
if (self.nextSlide && !self.goToQuiz){
self.slide = self.nextSlide;
self.index++;
@@ -369,7 +358,7 @@ odoo.define('website_slides.fullscreen', function (require) {
},
_onMiniQuizClick: function (ev){
var self = this;
- self.index = parseInt($(ev.currentTarget).attr('index'));
+ self.index = parseInt($(ev.currentTarget).attr('index')) || 0;
self.slide = self.slides[self.index];
self.goToQuiz = true;
self._setPreviousAndNextSlides();
@@ -378,6 +367,9 @@ odoo.define('website_slides.fullscreen', function (require) {
self._updateUrl();
history.pushState(null,'' ,self.url);
},
+ _onQuizCompleted: function (ev) {
+ this._setSlideStateAsDone();
+ },
/**
* @private
* Binds events related to the list
diff --git a/addons/website_slides/static/src/js/slides_course_quiz.js b/addons/website_slides/static/src/js/slides_course_quiz.js
index 3b72a5ab7b1..82aff54d46c 100644
--- a/addons/website_slides/static/src/js/slides_course_quiz.js
+++ b/addons/website_slides/static/src/js/slides_course_quiz.js
@@ -5,203 +5,346 @@ odoo.define('website_slides.quiz', function (require) {
var core = require('web.core');
var QWeb = core.qweb;
+ var _t = core._t;
+ /**
+ * This widget is responsible of displaying quiz questions and propositions. Submitting the quiz will fetch the
+ * correction and decorate the answers according to the result. Error message or modal can be displayed.
+ *
+ * This widget can be attached to DOM rendered server-side by `website_slides.slide_type_quiz` or
+ * used client side (Fullscreen).
+ *
+ * Triggered events are :
+ * - quiz_next_slide: need to go to the next slide, when quiz is done. Event data contains the current slide id.
+ * - quiz_completed: when the quiz is passed and completed by the user. Event data contains completion
+ * percentage and current slide id.
+ */
var Quiz= publicWidget.Widget.extend({
- /**
+ template: 'slide.slide.quiz',
+ xmlDependencies: ['/website_slides/static/src/xml/slide_quiz.xml'],
+ events: {
+ "click .o_wslides_quiz_answer": '_onAnswerClick',
+ "click .o_wslides_js_lesson_quiz_submit": '_onSubmitQuiz',
+ "click .o_wslides_quiz_btn": '_onClickNext',
+ "click .o_wslides_quiz_continue": '_onClickNext'
+ },
+
+ /**
* @override
- * @param {Object} el
- * @param {Object} data holding all the slide elements needed for the quiz
- * It will either come from the fullscreen widget or the sAnimation at the end of this file
+ * @param {Object} parent
+ * @param {Object} slide_data holding all the classic slide informations
+ * @param {Object} quiz_data : optional quiz data to display. If not given, will be fetched. (questions and answers).
*/
- init: function (el, data, nextSlide){
- this.slide = data;
- this.nextSlide = nextSlide;
- this.answeredQuestions = [];
- this.fullscreen = el;
- return this._super.apply(this,arguments);
- },
- start: function (){
- var self = this;
- self._bindQuizEvents();
- /**
- * If the quiz is rendered by the server instead of the fullscreen widget,
- * questions and their answers will have to be created manually from attributes
- */
- if (self.slide.quiz.questions.length === 0){
- this._setQuestions();
- }
+ init: function (parent, slide_data, quiz_data) {
+ this.slide = _.defaults(slide_data, {
+ id: 0,
+ name: '',
+ hasNext: false,
+ completed: false,
+ readonly: false,
+ });
+ this.quiz = quiz_data || false;
+ this.readonly = slide_data.readonly || false;
return this._super.apply(this, arguments);
},
- _renderSuccessModal: function (data){
- var self =this;
- $('.o_wslides_fs_quiz').append(QWeb.render('website.course.quiz.success', {
- data: data,
- nextSlide: self.nextSlide
- }));
- $('.submit-quiz').remove();
- $('.next-slide').css('display', 'inline-block');
- $('.next-slide').click(function (){
- self.fullscreen._goToNextSlide();
- });
- $('.o_wslides_quiz_success_modal_close').click(function (){
- $('.o_wslides_quiz_success_modal').remove();
- $('.o_wslides_quiz_modal_background').remove();
- });
- $(".o_wslides_quiz_modal_background").click(function (ev){
- $(ev.currentTarget).remove();
- $('.o_wslides_quiz_success_modal').remove();
+
+ /**
+ * @override
+ */
+ willStart: function () {
+ var def = new Promise(function () {});
+ if (this.quiz) {
+ def.resolve();
+ } else {
+ def = this._fetchQuiz();
+ }
+ return Promise.all([this._super.apply(this, arguments), def]);
+ },
+
+ /**
+ * @override
+ */
+ start: function() {
+ var self = this;
+ return this._super.apply(this, arguments).then(function () {
+ self._renderAnswers();
+ self._renderAnswersHighlighting();
+ self._renderValidationInfo();
});
},
+
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
- /**
- * @private
- * In case the quiz is rendered by the server and the data don't come from the fullscreen widget,
- * questions and their answers will have to be set here by using attributes
- */
- _setQuestions: function (){
- var self = this;
- $('.o_wslides_quiz_question').each(function (){
- self.slide.quiz.questions.push({
- id: parseInt($(this).attr('id')),
- title: $(this).attr('title'),
- answers: []
- });
- });
- for (var i = 0; i < self.slide.quiz.questions.length; i++){
- self._setAnswersForQuestion(self.slide.quiz.questions[i]);
+
+ _alertHide: function () {
+ this.$('.o_wslides_js_lesson_quiz_error').addClass('d-none');
+ },
+
+ _alertShow: function (alert_code) {
+ var message = _t('There was an error validating this quiz.');
+ if (! alert_code || alert_code === 'slide_quiz_incomplete') {
+ message = _t('All questions must be answered !');
+ }
+ else if (alert_code === 'slide_quiz_done') {
+ message = _t('This quiz is already done. Retaking it is not possible.');
}
+ this.$('.o_wslides_js_lesson_quiz_error span').html(message);
+ this.$('.o_wslides_js_lesson_quiz_error').removeClass('d-none');
},
- _setAnswersForQuestion: function (question){
- $('.o_wslides_quiz_answer[question_id='+question.id+']').each(function (){
- question.answers.push({
- id: parseInt($(this).attr('id')),
- text: $(this).attr('text_value'),
- is_correct: $(this).attr('is_correct')
- });
+
+ /*
+ * @private
+ * Fetch the quiz for a particular slide
+ */
+ _fetchQuiz: function () {
+ var self = this;
+ return self._rpc({
+ route:'/slides/slide/quiz/get',
+ params: {
+ 'slide_id': self.slide.id,
+ }
+ }).then(function (quiz_data) {
+ self.quiz = quiz_data;
});
},
- _updateProgressbar: function (){
+
+ /**
+ * @private
+ * Decorate the answers according to state
+ */
+ _renderAnswers: function () {
var self = this;
- var completion = self.channelCompletion <= 100 ? self.channelCompletion : 100;
- $('.o_wslides_fs_sidebar_progress_gauge').css('width', completion + "%" );
- $('.o_wslides_progress_percentage').text(completion);
+ this.$('input[type=radio]').each(function () {
+ console.log($(this));
+ $(this).prop('disabled', self.slide.readonly);
+ });
},
- _bindQuizEvents: function (){
+
+ /**
+ * @private
+ * Decorate the answer inputs according to the correction
+ */
+ _renderAnswersHighlighting: function () {
var self = this;
- if (!self.slide.done){
- $('.o_wslides_quiz_answer').each(function (){
- $(this).click(self._onAnswerClick.bind(self));
- });
+ this.$('li.o_wslides_quiz_answer').each(function () {
+ var $answer = $(this);
+ var answerId = $answer.data('answerId');
+ if (_.contains(self.quiz.goodAnswers, answerId)) {
+ $answer.removeClass('border-danger').addClass('border border-success');
+ $answer.find('i.fa').addClass('d-none');
+ $answer.find('i.fa-check-circle').removeClass('d-none');
+ }
+ else if (_.contains(self.quiz.badAnswers, answerId)) {
+ $answer.removeClass('border-success').addClass('border border-danger');
+ $answer.find('i.fa').addClass('d-none');
+ $answer.find('i.fa-times-circle').removeClass('d-none');
+ $answer.find('label input').prop('checked', false);
+ }
+ else {
+ $answer.removeClass('border border-danger border-success');
+ $answer.find('i.fa').addClass('d-none');
+ $answer.find('i.fa-circle').removeClass('d-none');
+ }
+ });
+ },
+
+ /**
+ * @private
+ * When the quiz is done and succeed, a congratulation modal appears.
+ */
+ _renderSuccessModal: function () {
+ var $modal = this.$('#slides_quiz_modal');
+ if (!$modal.length) {
+ this.$el.append(QWeb.render('slide.slide.quiz.finish', {'widget': this}));
+ $modal = this.$('#slides_quiz_modal');
}
- $('.submit-quiz').click(self._onSubmitQuiz.bind(self));
+ $modal.modal({
+ 'show': true,
+ });
+ $modal.on('hidden.bs.modal', function () {
+ $modal.remove();
+ });
+ },
+
+ /*
+ * @private
+ * Update validation box (karma, buttons) according to widget state
+ */
+ _renderValidationInfo: function () {
+ var $validationElem = this.$('.o_wslides_js_lesson_quiz_validation');
+ $validationElem.html(
+ QWeb.render('slide.slide.quiz.validation', {'widget': this})
+ );
+ },
+
+ /**
+ * Set the slide as completed and done. Trigger up the completion.
+ *
+ * @param {Integer} completion
+ */
+ _setCompleted: function () {
+ this.trigger_up('quiz_completed', {
+ 'slideId': this.slide.id,
+ });
},
- _highlightAnswers: function (answers){
+ /*
+ * Submit the given answer, and display the result
+ *
+ * @param Array checkedAnswerIds: list of checked answers
+ */
+ _submitQuiz: function (checkedAnswerIds) {
var self = this;
- self.answeredQuestions = [];
- for (var i = 0; i < answers.goodAnswers.length; i++){
- $('#answer'+ answers.goodAnswers[i] +'').addClass('o_wslides_quiz_good_answer');
- $('#answer'+ answers.goodAnswers[i] +' .o_wslides_quiz_radio_box span').replaceWith($('<i class="fa fa-check-circle"></i>'));
- var questionID =$('#answer'+ answers.goodAnswers[i] +' .o_wslides_quiz_radio_box input').attr('question_id');
- self.answeredQuestions.push(questionID);
- $('.o_wslides_quiz_answer[question_id='+questionID+']:not(.o_wslides_quiz_good_answer)').addClass('o_wslides_quiz_ignored_answer');
- $('.o_wslides_quiz_answer[question_id='+questionID+']').each(function (){
- $(this).unbind('click');
- });
- $('input[question_id='+questionID+']').each(function (){
- $(this).prop('disabled',true);
- });
- }
- for (i = 0; i < answers.badAnswers.length; i++){
- $('#answer'+ answers.badAnswers[i]).removeClass('o_wslides_quiz_good_answer')
- .addClass('o_wslides_quiz_bad_answer')
- .unbind('click');
- $('#answer'+ answers.badAnswers[i] +' .o_wslides_quiz_radio_box span').replaceWith($('<i class="fa fa-times "></i>'));
- $('#answer'+ answers.badAnswers[i] +' .o_wslides_quiz_radio_box input').prop('checked', false);
- }
+ return this._rpc({
+ route: '/slides/slide/quiz/submit',
+ params: {
+ slide_id: self.slide.id,
+ answer_ids: checkedAnswerIds,
+ }
+ }).then(function(data){
+ if (data.error) {
+ self._alertShow(data.error);
+ } else {
+ self.slide.completed = data.completed;
+ self.quiz = _.extend(self.quiz, data);
+ self._renderAnswersHighlighting();
+ self._renderValidationInfo();
+ if (self.slide.completed) {
+ self._renderSuccessModal(data);
+ self._setCompleted();
+ }
+ }
+ });
},
+
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
- _onAnswerClick: function (ev){
- var self = this;
- var target = $(ev.currentTarget);
- if ((self.answeredQuestions.indexOf(target.attr('question_id'))) === -1){
- var id = target.attr('id');
- var question_id = target.attr('question_id');
- $('.o_wslides_quiz_answer[question_id='+question_id+']').removeClass('o_wslides_quiz_good_answer');
- $('#'+id+' input[type=radio]').prop('checked', true);
+
+ /**
+ * When clicking on an answer, this one should be marked as "checked".
+ *
+ * @private
+ * @param OdooEvent ev
+ */
+ _onAnswerClick: function (ev) {
+ if (! this.slide.readonly) {
+ $(ev.currentTarget).find('input[type=radio]').prop('checked', true);
}
+ this._alertHide();
},
- _onSubmitQuiz: function (){
- var self = this;
- var inputs = $('input[type=radio]:checked');
+ /**
+ * Triggering a event to switch to next slide
+ *
+ * @private
+ * @param OdooEvent ev
+ */
+ _onClickNext: function (ev) {
+ if (this.slide.hasNext) {
+ this.trigger_up('quiz_next_slide', {
+ 'slideId': this.slide.id,
+ });
+ }
+ },
+ /**
+ * Submit a quiz and get the correction. It will display messages
+ * according to quiz result.
+ *
+ * @private
+ * @param OdooEvent ev
+ */
+ _onSubmitQuiz: function (ev) {
+ var inputs = this.$('input[type=radio]:checked');
var values = [];
for (var i = 0; i < inputs.length; i++){
values.push(parseInt($(inputs[i]).val()));
}
- if (values.length === self.slide.quiz.questions.length){
- $('.quiz-danger').remove();
- self._rpc({
- route: "/slide/quiz/submit",
- params: {
- slide_id: self.slide.id,
- answer_ids: values,
- quiz_id: self.slide.quiz_id
- }
- }).then(function (data){
- self._highlightAnswers(data);
- self.slide.quiz.nb_attempts = data.attempts_count;
- if (data.passed){
- self.channelCompletion = data.channel_completion;
- self._updateProgressbar();
- $('#check-'+self.slide.id).replaceWith($('<i class="check-done o_wslides_slide_completed fa fa-check-circle"></i>'));
- self.slide.done = true;
- self._renderSuccessModal(data);
- }
- else {
- var points = self.slide.quiz.nb_attempts < self.slide.quiz.possible_rewards.length ? self.slide.quiz.possible_rewards[self.slide.quiz.nb_attempts] : self.slide.quiz.possible_rewards[self.slide.quiz.possible_rewards.length-1];
- $('#quiz-points').text(points);
- }
- });
+
+ if (values.length === this.quiz.questions.length){
+ this._alertHide();
+ this._submitQuiz(values);
} else {
- $('#quiz_buttons').append($('<p class="quiz-danger text-danger mt-1">All questions must be answered !</p>'));
+ this._alertShow();
}
},
-});
+ });
publicWidget.registry.websiteSlidesQuizNoFullscreen = publicWidget.Widget.extend({
- selector: '.o_w_slides_quiz_no_fullscreen',
- xmlDependencies: ['/website_slides/static/src/xml/website_slides_fullscreen.xml'],
- init: function (el){
- this._super.apply(this, arguments);
- },
- start: function (){
- this._super.apply(this, arguments);
- var slideID = parseInt(this.$el.attr('slide_id'),10);
- var slideDone = this.$el.attr('slide_done');
- var nbAttempts = parseInt(this.$el.attr('nb_attempts'), 10);
- var firstAttemptReward = this.$el.attr('first_reward');
- var secondAttemptReward = this.$el.attr('second_reward');
- var thirdAttemptReward = this.$el.attr('third_reward');
- var fourthAttemptReward = this.$el.attr('fourth_reward');
- var possibleRewards = [firstAttemptReward,secondAttemptReward,thirdAttemptReward,fourthAttemptReward];
- var data = {
- id: slideID,
- done: slideDone,
- quiz: {
- questions: [],
- nb_attempts: nbAttempts,
- possible_rewards: possibleRewards,
- reward: nbAttempts < possibleRewards.length ? possibleRewards[nbAttempts] : possibleRewards[possibleRewards.length-1]
- }
- };
- if (!slideDone){
- var NewQuiz = new Quiz(this, data);
- NewQuiz.appendTo(".o_w_slides_quiz_no_fullscreen");
- }
- }
+ selector: '.o_wslides_js_lesson_quiz',
+ custom_events: {
+ quiz_next_slide: '_onQuizNextSlide',
+ quiz_completed: '_onQuizCompleted',
+ },
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ * @param {Object} parent
+ */
+ start: function () {
+ var self = this;
+ var defs = [this._super.apply(this, arguments)];
+ $('.o_wslides_js_lesson_quiz').each(function () {
+ var slideData = $(this).data();
+ slideData.quizData = {
+ questions: self._extractQuestionsAndAnswers(),
+ quizKarmaMax: slideData.quizKarmaMax,
+ quizKarmaWon: slideData.quizKarmaWon,
+ quizKarmaGain: slideData.quizKarmaGain,
+ quizAttemptsCount: slideData.quizAttemptsCount,
+ };
+ defs.push(new Quiz(self, slideData, slideData.quizData).attachTo($(this)));
+ });
+ return Promise.all(defs);
+ },
+
+ //----------------------------------------------------------------------
+ // Handlers
+ //---------------------------------------------------------------------
+
+ _onQuizCompleted: function (slideId) {
+ console.log('set completed', slideId);
+ },
+
+ _onQuizNextSlide: function () {
+ var url = this.$el.data('next-slide-url');
+ window.location.replace(url);
+ },
+
+ //----------------------------------------------------------------------
+ // Private
+ //---------------------------------------------------------------------
+
+ /**
+ * Extract data from exiting DOM rendered server-side, to have the list of questions with their
+ * relative answers.
+ * This method should return the same format as /slide/quiz/get controller.
+ *
+ * @return {Array<Object>} list of questions with answers
+ */
+ _extractQuestionsAndAnswers: function() {
+ var questions = [];
+ this.$('.o_wslides_js_lesson_quiz_question').each(function () {
+ var $question = $(this);
+ var answers = [];
+ $question.find('.o_wslides_quiz_answer').each(function () {
+ var $answer = $(this);
+ answers.push({
+ id: $answer.data('answerId'),
+ text: $answer.data('text'),
+ });
+ });
+ questions.push({
+ id: $question.data('questionId'),
+ title: $question.data('title'),
+ answers: answers,
+ });
+ });
+ return questions;
+ },
});
return Quiz;
diff --git a/addons/website_slides/static/src/js/slides_course_slides_list.js b/addons/website_slides/static/src/js/slides_course_slides_list.js
index ac11e94259e..305a1fd7b86 100644
--- a/addons/website_slides/static/src/js/slides_course_slides_list.js
+++ b/addons/website_slides/static/src/js/slides_course_slides_list.js
@@ -28,17 +28,17 @@ publicWidget.registry.websiteSlidesCourseSlidesList = publicWidget.Widget.extend
* @private
*/
_bindSortable: function () {
- this.$('ul.o_wslides_slides_list_container').sortable({
+ this.$('ul.o_wslides_js_slides_list_container').sortable({
handle: '.fa-arrows',
stop: this._reorderCategories.bind(this),
items: '.o_wslides_slide_list_category'
});
- this.$('.o_wslides_slides_list_container ul').sortable({
+ this.$('.o_wslides_js_slides_list_container ul').sortable({
handle: '.fa-arrows',
- connectWith: '.o_wslides_slides_list_container ul',
+ connectWith: '.o_wslides_js_slides_list_container ul',
stop: this._reorderSlides.bind(this),
- items: '.o_wslides_slides_list_slide:not(.o_wslides_empty_category)'
+ items: '.o_wslides_slides_list_slide:not(.o_wslides_js_slides_list_empty)'
});
},
@@ -50,8 +50,8 @@ publicWidget.registry.websiteSlidesCourseSlidesList = publicWidget.Widget.extend
* @private
*/
_checkForEmptySections: function (){
- this.$('.o_wslides_slides_list_container ul').each(function (){
- var $emptyCategory = $(this).find('.o_wslides_empty_category');
+ this.$('.o_wslides_js_slides_list_container ul').each(function (){
+ var $emptyCategory = $(this).find('.o_wslides_js_slides_list_empty');
if ($(this).find('li.o_wslides_slides_list_slide[data-slide-id]').length === 0) {
$emptyCategory.removeClass('d-none').addClass('d-flex');
} else {
@@ -130,7 +130,7 @@ publicWidget.registry.websiteSlidesCourseSlidesList = publicWidget.Widget.extend
* @private
*/
_updateHref: function () {
- this.$(".o_wslides_slides_list_slide_link").each(function (){
+ this.$(".o_wslides_js_slides_list_slide_link").each(function (){
var href = $(this).attr('href');
var operator = href.indexOf('?') !== -1 ? '&' : '?';
$(this).attr('href', href + operator + "fullscreen=1");
diff --git a/addons/website_slides/static/src/js/slides_share.js b/addons/website_slides/static/src/js/slides_share.js
index 81ba1e4a806..775dc5ccce6 100644
--- a/addons/website_slides/static/src/js/slides_share.js
+++ b/addons/website_slides/static/src/js/slides_share.js
@@ -128,13 +128,14 @@ publicWidget.registry.websiteSlidesShare = publicWidget.Widget.extend({
* @param {Object} ev
*/
_onSlidesSocialShare: function (ev) {
+ var self = this;
ev.preventDefault();
var key = $(ev.currentTarget).attr('social-key');
var popUpURL = $(ev.currentTarget).attr('href');
var popUp = window.open(popUpURL, 'Share Dialog', 'width=626,height=436');
$(window).on('focus', function () {
if (popUp.closed) {
- this._updateStatistics(key, this.slide_url);
+ self._updateStatistics(key, self.slide_url);
$(window).off('focus');
}
});
diff --git a/addons/website_slides/static/src/scss/slide_slide.scss b/addons/website_slides/static/src/scss/slide_slide.scss
deleted file mode 100644
index 2aeac35b889..00000000000
--- a/addons/website_slides/static/src/scss/slide_slide.scss
+++ /dev/null
@@ -1,156 +0,0 @@
-.o_wslides_slide_header {
- background-color: #62495B;
- height: 200px;
-}
-
-.o_wslides_slide_header_container {
- width: 1000px;
- margin: 0 auto;
- height: 100%;
-}
-
-.o_wslides_slide_header_box{
- display: flex;
- height: 100%;
- width: 50%;
- flex-direction: column;
- justify-content: flex-end;
- padding-bottom: 50px;
-
- a {
- color: #fff;
- font-size: 2.5rem;
- text-decoration: none !important;
- }
-}
-
-
-.o_wslides_slide_content_list {
- position: absolute;
- top: 140px;
- left: 80px;
- background-color: #fff;
- height: 500px;
- z-index: 200;
-}
-
-.o_wslides_slide_list{
- background-color: #fff;
- list-style: none;
- height: 100%;
- padding: 0;
- overflow: auto;
- border: 1px solid #ccc;
- border-left: none;
- box-sizing: border-box;
- flex: 1;
-
- a {
- text-decoration: none;
- color: $text-muted;
- }
-
- a .active{
- background-color: #eee !important;
- }
-}
-
-.o_wslides_slide_content_list_section{
- border-bottom: 1px solid #fff;
- color: #9B9B9B;
- text-transform: uppercase;
- padding: 10px 15px;
-}
-
-.o_wslides_slide_content_list_slide{
- color: #666666;
- padding: 10px 15px;
-}
-
-.o_wslides_slide_list_cell{
- word-wrap: none;
- padding: 0.5rem 1rem;;
- height: 40px;
- box-sizing: border-box;
- display: flex;
- align-items: center;
- text-decoration: none !important;
-}
-
-.o_wslides_slide_list_cell:hover{
- background-color: #eee;
- cursor: pointer;
-}
-
-.o_wslides_slide_list_cell:active{
- background-color: #eee;
- cursor: pointer;
-}
-
-
-.o_wslides_slide_content_list_header {
- padding: 10px 15px;
- font-size: 1.2rem;
- background-color: #F7F7F7;
- color: #6F6F6F;
-}
-
-.o_wslides_slide_button {
- color: #666;
- text-decoration: none;
- border:1px solid #B9B9B9;
- padding: 5px 10px;
- width: 50px;
- text-align: center;
- text-transform: uppercase;
- margin: 0;
- border-radius: 3px;
-}
-
-.o_wslides_slide_button_fullscreen {
- color: #666;
- text-decoration: none;
- border:1px solid #B9B9B9;
- padding: 5px 10px;
- text-align: center;
- text-transform: uppercase;
- margin: 0;
- border-radius: 3px;
-}
-
-.o_wslides_slide_button_done {
- color: #fff;
- text-transform: uppercase;
- background-color: $primary;
- text-decoration: none;
- padding: 10px 15px;
- width: 50px;
- text-align: center;
- border-radius: 3px;
- margin: 0;
-}
-
-.o_wslides_slide_title {
- width: 60%;
- display: flex;
- align-items: center;
-
- h1 {
- font-size: 1.5rem;
- }
-
- .o_wslides_slide_points {
- margin-left: 20px;
- padding: 5px 10px;
- background-color: #DC9D45;
- border-radius: 10px;
- color: #fff;
- font-weight: bold;
- }
-}
-
-.o_wslides_slide_documentation_sidebar {
- position: absolute;
- top: 300px;
- left: 30px;
-} \ No newline at end of file
diff --git a/addons/website_slides/static/src/scss/slides_slide_fullscreen.scss b/addons/website_slides/static/src/scss/slides_slide_fullscreen.scss
index f6612d5d04f..08ef8bc299b 100644
--- a/addons/website_slides/static/src/scss/slides_slide_fullscreen.scss
+++ b/addons/website_slides/static/src/scss/slides_slide_fullscreen.scss
@@ -268,5 +268,44 @@
.o_wslides_fs_player_no_sidebar {
max-width: 100%;
width: 100%;
- }
+ }
+
+ // quizz
+ .o_wslides_fs_quiz_container {
+ background-color: $gray-200;
+ height: 100%;
+ overflow: scroll;
+ overflow-x: hidden;
+ width: 100%;
+
+ .o_wslides_js_lesson_quiz_question {
+ li {
+ font-size: 1.2rem;
+ }
+
+ .o_wslides_quiz_answer {
+
+ &:hover {
+ cursor: pointer;
+ }
+
+ label {
+ font-size: 1.4rem;
+
+ i.fa-circle {
+ }
+
+ input:checked + i.fa-circle {
+ color: $primary !important;
+ overflow: hidden;
+ }
+ }
+ }
+ }
+ }
+
+ // Tools
+ .bg-brand {
+ background-color: $o-enterprise-color;
+ }
}
diff --git a/addons/website_slides/static/src/scss/website_slides.scss b/addons/website_slides/static/src/scss/website_slides.scss
index cf6bed1830e..4e83de3b44d 100644
--- a/addons/website_slides/static/src/scss/website_slides.scss
+++ b/addons/website_slides/static/src/scss/website_slides.scss
@@ -2,18 +2,12 @@
$nav-tabs-border-color: #dddddd !default;
$body-bg: #FFFFFF !default;
$MAX-Z-INDEX : 2147483647 !default;
-
+$gray-50: #f4f4f4 !default;
+$o-enterprise-radient-color: #62495B !default;
// Common to new slides pages
// **************************************************
-.o_wslides_container {
- margin: auto;
- width: 100%;
- max-width: 1140px;
- padding: 0px 32px 0px 32px;
-}
-
.o_wslides_wrap {
background-color: desaturate(map-get($grays, "200"), 80%);
@@ -42,6 +36,31 @@ $MAX-Z-INDEX : 2147483647 !default;
color: $link-color;
}
+ .o_wslides_js_lesson_quiz_question {
+ li {
+ font-size: 1.2rem;
+ }
+
+ .o_wslides_quiz_answer {
+
+ &:hover {
+ cursor: pointer;
+ }
+
+ label {
+ font-size: 1.4rem;
+
+ i.fa-circle {
+ }
+
+ input:checked + i.fa-circle {
+ color: $primary !important;
+ overflow: hidden;
+ }
+ }
+ }
+ }
+
// tools
// ****************************************
@@ -53,28 +72,19 @@ $MAX-Z-INDEX : 2147483647 !default;
opacity: 0.5;
}
- .o_wslides_home_circled {
- border-radius: 50%;
- width: 1.2em;
- height: 1.2em;
- padding: 0.25em 0.5em 0.25em 0.5em;
- border: 0.2em solid #666;
- text-align: center;
- box-sizing: initial;
+ .bg-brand {
+ background-color: $o-enterprise-color;
}
.progress {
overflow: visible;
- margin-bottom: 0em;
- height: 4px;
.progress-bar {
- background-color: #00A09D;
+ background-color: $primary;
position: relative;
- border-radius: 4px;
span {
- background-color: #00A09D;
+ background-color: $primary;
position: absolute;
bottom: -20px;
font-size: 10px;
@@ -107,16 +117,16 @@ $MAX-Z-INDEX : 2147483647 !default;
// **************************************************
.o_wslides_home_main {
- .o_wslides_home_aside {
- .fa-check {
- color: #00A09D;
- font-size: 1.2em;
- }
- }
-
.o_wprofile_next_rank_card {
background-color: transparent !important;
}
+
+ .o_wslides_home_aside_loggedin {
+ @include media-breakpoint-up(lg) {
+ background: none;
+ border: none;
+ }
+ }
}
// New course page
@@ -214,49 +224,33 @@ $MAX-Z-INDEX : 2147483647 !default;
}
.o_wslides_course_main {
- .o_wslides_course_content_aside {
- max-width: 286px;
- .bg-white {
- max-width: 256px;
- background-color: white;
- padding-bottom: 8px;
- > div.row {
- padding-left: 15px;
- padding-right: 15px;
- }
- }
+ .o_wslides_course_content_aside {
+ max-width: 286px;
+ .bg-white {
+ max-width: 256px;
+ background-color: white;
+ padding-bottom: 8px;
+ > div.row {
+ padding-left: 15px;
+ padding-right: 15px;
+ }
}
+ }
}
// Slides list reordering widget
.o_wslides_slides_list {
- padding: 0;
-
- ul {
- list-style: none;
- padding: 0;
- }
-
- .o_wslides_slide_list_category_container {
- margin: 0;
- background-color: #ddd;
- display: flex;
- list-style: none;
- font-size: 1.05rem;
- border-bottom: 1px solid #ccc;
- }
-
.o_wslides_slides_list_slide {
&.o_not_editable {
height: 0px;
}
&:nth-child(odd) {
- background-color: #f6f6f6;
+ background-color: $gray-50;
}
&:nth-child(even) {
- background-color: #f9f9f9;
+ background-color: $gray-100;
}
.o_wslides_slides_list_slide_controls {
@@ -272,6 +266,64 @@ $MAX-Z-INDEX : 2147483647 !default;
// New lesson page
// **************************************************
+.o_wslides_lesson_header {
+ background: linear-gradient(150deg, $o-enterprise-color 20%, $o-enterprise-radient-color 80%);
+ margin: auto;
+
+ .o_wslides_lesson_header_container {
+ a:hover {
+ text-decoration: none;
+ }
+
+ .fa-trophy {
+ font-size: 2em;
+ }
+ }
+}
+
+.o_wslides_lesson_main {
+ .o_wslides_lesson_aside {
+ .o_wslides_lesson_aside_list,
+ .o_wslides_lesson_aside_doc {
+ border: 0;
+ border-bottom: 3px solid $border-color;
+ position: relative;
+ top: -50px;
+
+ .nav-tabs .nav-link {
+ border-top: 0;
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ }
+ }
+
+ .o_wslides_lesson_aside_list {
+ .o_wslides_lesson_aside_list_link {
+ text-decoration: none;
+
+ &.active {
+ background-color: $gray-200;
+ }
+ }
+ }
+ }
+
+ .o_wslides_lesson_content {
+ .o_wslides_lesson_nav {
+ .nav-link {
+ background-color: transparent;
+ border: 0;
+ border-bottom: 1px solid $border-color;
+ color: $gray-600;
+
+ &.active {
+ border-bottom: 1px solid $success;
+ color: $gray-800;
+ }
+ }
+ }
+ }
+}
// Old brol to clean someday
// **************************************************
diff --git a/addons/website_slides/static/src/scss/website_slides_profile.scss b/addons/website_slides/static/src/scss/website_slides_profile.scss
index a2a235f3bb4..35f85b4c053 100644
--- a/addons/website_slides/static/src/scss/website_slides_profile.scss
+++ b/addons/website_slides/static/src/scss/website_slides_profile.scss
@@ -1,43 +1,4 @@
-.o_wprofile_slides_card {
- padding: 8px;
- max-height: 68px;
- min-width: 350px;
-}
-.o_wprofile_slides_title {
- top: -15px;
- padding-left: 76px;
-}
-
// Quest - Course Card
.o_wprofile_slides_course_card_body {
cursor: pointer;
- height: 66px;
- .o_wprofile_slides_course_img {
- position: absolute;
- padding: 0px;
- top: 8px;
- left: 8px;
- width: 66px;
- height: 66px;
- }
- .o_wprofile_slide_course_rating {
- position: absolute;
- top: 13px;
- right: 16px;
- }
- .o_wprofile_slide_course_progress {
- position: absolute;
- bottom: 4px;
- width: 100%;
- padding-left: 88px;
- .o_wprofile_slide_course_progress_bar {
- background-color: lightgray;
- top: 2px;
- padding: 0px;
- }
- .o_wprofile_slide_course_progress_value {
- min-width: 70px;
- text-align: right;
- }
- }
}
diff --git a/addons/website_slides/static/src/scss/website_slides_quiz.scss b/addons/website_slides/static/src/scss/website_slides_quiz.scss
deleted file mode 100644
index cca013c0be9..00000000000
--- a/addons/website_slides/static/src/scss/website_slides_quiz.scss
+++ /dev/null
@@ -1,237 +0,0 @@
-
- .o_wslides_fs_quiz {
- background-color: #F6F6F6;
- width: 100%;
- height: 100%;
- overflow: scroll;
- overflow-x: hidden;
- padding-bottom: 50px;
-
- }
-
- .o_wslides_quiz_question {
- margin-top: 50px;
- width: 100%;
- .o_wslides_quiz_question_title {
- font-size: 1.5rem;
- margin-bottom: 20px;
- .o_wslides_quiz_question_title_number {
- color: #8C8C8C;
- font-size: 1.3rem;
- font-weight: bold;
- }
-
- .o_wslides_quiz_question_title_text {
- margin-left: 2px;
- color: #2F2F2F;
- }
- }
-
- .o_wslides_quiz_question_answers{
- background-color: #fff;
- list-style: none;
- padding: 0;
-
- .o_wslides_quiz_answer {
- padding: 10px 20px;
- border-bottom: 1px solid #B6B6B6;
- font-size: 1.2rem;
- display: flex;
- align-items: center;
-
- label.o_wslides_quiz_radio_box{
- background-color: #f6f6f6;
- width: 35px;
- height: 35px;
- display: inline-flex;
- border-radius: 50%;
- overflow: hidden;
- justify-content: center;
- align-items: center;
- margin: 0;
- margin-right: 20px;
- font-size: 1.2rem;
- outline: none;
- border: none;
- padding: 0;
- }
-
- label.o_wslides_quiz_radio_box input{
- display:none;
- outline: none;
- border: none;
- }
-
-
- label.o_wslides_quiz_radio_box span{
- width: 18px;
- height: 18px;
- margin: 0;
- }
-
- label.o_wslides_quiz_radio_box input:checked + span{
- background-color: $primary;
- border-radius: 50%;
- overflow: hidden;
- }
-
- label.o_wslides_quiz_radio_box input:checked{
- background-color: #C7EAE9;
- }
- }
-
- .o_wslides_quiz_answer:not(.o_wslides_quiz_good_answer,.o_wslides_quiz_bad_answer,.o_wslides_quiz_ignored_answer):hover {
- cursor: pointer;
- }
-
- .o_wslides_quiz_good_answer{
- border: 2px solid #28A745;
- label.o_wslides_quiz_radio_box input:checked + i{
- height: 100%;
- width: 100%;
- background-color: #fff;
- color: #28A745;
- display: flex;
- justify-content: center;
- align-items: center;
- font-size: 1.4rem;
- border: 10px solid #28A745;
- border-radius: 50px;
- }
- }
-
- .o_wslides_quiz_bad_answer{
- border: 2px solid #A94442;
-
- label.o_wslides_quiz_radio_box i{
- background-color: #A94442;
- color: white;
- height: 100%;
- width: 100%;
- font-size: 1rem;
- display: flex;
- justify-content: center;
- align-items: center;
- }
- }
-
-
- .quiz-ignored-answer {
- color: $text-muted;
- }
- }
- }
-
- .o_wslides_quiz_modal_background{
- position: absolute;
- top: 0;
- left: 0;
- background-color: rgba(0,0,0,.2);
- width: 100%;
- height: 100%;
-
- }
-
- .o_wslides_quiz_success_modal {
- display: flex;
- position: absolute;
- width: 40%;
- max-width: 600px;
- height: 450px;
- background-color: #fff;
- top: 50%;
- left: 50%;
- transform: translateX(-50%) translateY(-50%);
- z-index: 2000;
- }
-
- .o_wslides_quiz_success_modal_left_panel {
- width: 50%;
- background-color: #855B79;
-
- }
-
- .o_wslides_quiz_success_modal_right_panel{
- width: 50%;
- display: flex;
- flex-direction: column;
- padding: 10px 20px 10px 50px;
- position: relative;
-
- .o_wslides_quiz_success_modal_close {
- position: absolute;
- top: 10px;
- right: 15px;
- text-transform: uppercase;
- color: #787878;
- font-size: 1.2rem;
- }
-
- .o_wslides_quiz_success_modal_close:hover{
- cursor: pointer;
- }
-
- h1 {
- color: #21272B;
- font-weight: bold;
- font-size: 2.3rem;
- padding: 20px 0;
- letter-spacing: 1px;
- }
-
- .o_wslides_quiz_success_progress_bar{
- height: 10px;
- width: 100%;
- background-color: rgba(0,0,0,.1);
-
- .o_wslides_quiz_success_progress_gauge{
- width: 75%;
- height: 100%;
- background-color: #01ADAB;
- }
- }
-
- .o_wslides_quiz_success_progress_bounds{
- display: flex;
- justify-content: space-between;
- }
-
- .o_wslides_quiz_success_progress_bounds{
- font-weight: bold;
- span:first-child {
- color: #019F9D;
- }
- span:last-child{
- color: #787878;
- }
- }
-
- .o_wslides_quiz_success_reward {
- height: 80px;
- width: 100%;
- border: 1px dashed #ccc;
- margin-top: 70px;
- }
-
- .o_wslides_quiz_success_button{
- flex: 1;
- display: flex;
- justify-content: flex-end;
- align-items: flex-end;
-
- a {
- color: #787878;
- text-transform: uppercase;
- background-color: #fff;
- border-radius: 3px;
- border: 1px solid #C4C4C4;
- font-weight: bold;
- padding: 5px 15px;
- text-align: center;
- }
-
- a:hover {
- cursor: pointer;
- }
- }
- } \ No newline at end of file
diff --git a/addons/website_slides/static/src/xml/slide_quiz.xml b/addons/website_slides/static/src/xml/slide_quiz.xml
new file mode 100644
index 00000000000..f512985a3af
--- /dev/null
+++ b/addons/website_slides/static/src/xml/slide_quiz.xml
@@ -0,0 +1,88 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+ <t t-name="slide.slide.quiz">
+ <div class="o_wslides_fs_quiz_container">
+ <div class="mx-5">
+ <div t-foreach="widget.quiz.questions" t-as="question"
+ class="o_wslides_js_lesson_quiz_question mt-3" t-att-data-question-id="question.id" t-att-data-title="question.question">
+ <div class="h3">
+ <small class="text-muted"><span t-esc="question_index+1"/>. </small> <span t-esc="question.question"/>
+ </div>
+ <ul class="bg-white list-unstyled">
+ <t t-foreach="question.answers" t-as="answer">
+ <li t-att-data-answer-id="answer.id"
+ t-att-data-text="answer.text_value"
+ class="o_wslides_quiz_answer pt-2 pb-2 border-bottom rounded d-flex align-items-center">
+
+ <label class="mb-0 d-flex align-items-center justify-content-center mr-3 ml-3">
+ <input type="radio"
+ t-att-name="question.id"
+ t-att-value="answer.id"
+ class="d-none"/>
+ <i class="fa fa-circle text-muted"></i>
+ <i class="fa fa-times-circle text-danger d-none"></i>
+ <i class="fa fa-check-circle text-success d-none"></i>
+ </label>
+ <span t-esc="answer.text_value"/>
+ </li>
+ </t>
+ </ul>
+ </div>
+ <div class="alert alert-danger o_wslides_js_lesson_quiz_error d-none" role="alert">
+ <span></span>
+ <button type="button" class="close" data-dismiss="alert" aria-label="Close">
+ <span aria-hidden="true">&amp;times;</span>
+ </button>
+ </div>
+ <div class="o_wslides_js_lesson_quiz_validation"/>
+ </div>
+ </div>
+ </t>
+
+ <t t-name="slide.slide.quiz.validation">
+ <t t-if="widget.readonly">
+ <button class="btn btn-primary" role="button" title="Join" aria-label="Join" disabled="disabled">Join course to take quiz</button>
+ <span role="button" title="Succeed and gain karma" aria-label="Succeed and gain karma"
+ class="badge badge-pill badge-warning text-white font-weight-bold ml-3 px-2 py-1">+ <t t-esc="widget.quiz.quizKarmaGain"/> XP</span>
+ </t>
+ <t t-else="">
+ <button t-if="! widget.slide.completed" role="button" title="Check answers" aria-label="Check answers"
+ class="btn btn-primary text-uppercase font-weight-bold o_wslides_js_lesson_quiz_submit">Check your answers</button>
+ <button t-if="widget.slide.completed" role="button" title="Quiz done" aria-label="Quiz done" disabled="disabled"
+ class="btn btn-primary text-uppercase font-weight-bold">Done !</button>
+ <span t-if="! widget.slide.completed" role="button" title="Succeed and gain karma" aria-label="Succeed and gain karma"
+ class="badge badge-pill badge-warning text-white font-weight-bold ml-3 px-2 py-1">+ <t t-esc="widget.quiz.quizKarmaGain"/> XP</span>
+ <span t-if="widget.slide.completed" role="button" title="Gained karma" aria-label="Gained karma"
+ class="badge badge-pill badge-success text-white font-weight-bold ml-3 px-2 py-1">+ <t t-esc="widget.quiz.quizKarmaWon"/> XP</span>
+ <button t-if="widget.slide.completed &amp;&amp; widget.slide.hasNext"
+ class="btn btn-primary ml-3 o_wslides_quiz_continue">Continue</button>
+ </t>
+ </t>
+
+ <t t-name="slide.slide.quiz.finish">
+ <div class="modal o_wslides_quiz_modal" tabindex="-1" role="dialog" id="slides_quiz_modal">
+ <div class="modal-dialog" role="document">
+ <div class="modal-content">
+ <div class="modal-body d-flex p-0">
+ <div class="w-50 bg-brand"></div>
+ <div class="w-50 d-flex flex-column p-3">
+ <h1>Amazing!</h1>
+ <div class="pb-3">
+ <span class="pb-2">You gained <span class="badge badge-pill badge-success text-white font-weight-bold"><t t-esc="widget.quiz.quizKarmaWon"/> XP !</span> !</span>
+ <div class="progress">
+ <div class="progress-bar" role="progressbar" t-att-aria-valuenow="widget.quiz.rankProgress.progress" aria-valuemin="0" aria-valuemax="100"
+ t-attf-style="heigth: 8px; width: #{widget.quiz.rankProgress.progress}%"/>
+ </div>
+ <small t-if="widget.quiz.rankProgress.lowerBound" class="float-left"><t t-esc="widget.quiz.rankProgress.lowerBound"/></small>
+ <small t-if="widget.quiz.rankProgress.upperBound" class="float-right"><t t-esc="widget.quiz.rankProgress.upperBound"/></small>
+ </div>
+ <div class="pb-3" t-raw="widget.quiz.rankProgress.motivational"/>
+ <button t-if="widget.slide.hasNext" type="button" class="btn btn-light o_wslides_quiz_btn align-self-end">Next <i class="fa fa-chevron-right"/></button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/website_slides/static/src/xml/website_slides_fullscreen.xml b/addons/website_slides/static/src/xml/website_slides_fullscreen.xml
index 1817d085f71..b364f486420 100644
--- a/addons/website_slides/static/src/xml/website_slides_fullscreen.xml
+++ b/addons/website_slides/static/src/xml/website_slides_fullscreen.xml
@@ -6,56 +6,12 @@
<div t-if="['presentation', 'document'].indexOf(slide.slide_type) !== -1 &amp;&amp; !showMiniQuiz" class="embed-responsive embed-responsive-4by3 embed-responsive-item ">
<iframe t-att-src="embed_url" class="o_wslides_iframe_viewer" allowFullScreen="true" height="315" width="420" frameborder="0"></iframe>
</div>
- <div style="width:100%;height:100%;text-align:center;" t-if="slide.slide_type == 'infographic' &amp;&amp; !showMiniQuiz">
+ <div t-if="slide.slide_type == 'infographic' &amp;&amp; !showMiniQuiz" style="width:100%;height:100%;text-align:center;">
<img t-attf-src="/web/image/slide.slide/#{slide.id}/image" class="img-fluid" style="height: 100%" alt="Slide image"/>
</div>
- <div t-if="(slide.slide_type == 'quiz' || showMiniQuiz) &amp;&amp; questions " class="o_wslides_fs_quiz">
- <div>
- <t t-call="website.slide.quiz"/>
- <div id="quiz_buttons" class="container">
- <button t-if="!slide.done" class="btn btn-primary submit-quiz" >Check your answers</button>
- <button t-if="embed_url" class="btn btn-primary back-to-video" >Watch again</button>
- <button t-if="slide.done &amp;&amp; nextSlide" class="btn btn-primary next-slide" >Continue</button>
- </div>
- </div>
- </div>
- <div t-if="slide.slide_type == 'webpage'" class="o_wslides_fs_webpage">
- <div class="o_wslides_fs_webpage_content container"/>
- </div>
<div class="o_wslides_fs_content"/>
</t>
- <t t-name="website.slide.quiz">
- <div class="container quiz-questions">
- <t t-set="i" t-value="1"/>
- <t t-foreach="questions" t-as="question">
- <t t-set="j" t-value="0"/>
- <div class="o_wslides_quiz_question">
- <div>
- <div class="o_wslides_quiz_question_title">
- <span class="o_wslides_quiz_question_title_number"><span t-esc="i"/>. </span>
- <span class="o_wslides_quiz_question_title_text" t-esc="question.title"/>
- </div>
- <ul class="o_wslides_quiz_question_answers">
- <t t-foreach="question.answers" t-as="answer">
- <li t-attf-id="answer#{answer.id}" t-att-question_id="question.id" t-att-class="(slide.done &amp;&amp; answer.correct) ? 'o_wslides_quiz_answer o_wslides_quiz_good_answer': slide.done ? 'o_wslides_quiz_answer o_wslides_quiz_ignored_answer' : 'o_wslides_quiz_answer'">
- <label class="o_wslides_quiz_radio_box">
- <input t-att-answer_id="answer.id" type="radio" t-att-name="question.id" t-att-question_id="question.id" t-att-value="answer.id" t-att-checked="(slide.done &amp;&amp; answer.correct) ? 'checked' : undefined" t-att-disabled="slide.done"/>
- <span t-if="!(slide.done &amp;&amp; answer.correct)"/>
- <i t-if="(slide.done &amp;&amp; answer.correct)" class="fa fa-check-circle"></i>
- </label>
- <span t-esc="answer.text"/>
- </li>
- <t t-set="j" t-value="j+1"/>
- </t>
- </ul>
- </div>
- </div>
- <t t-set="i" t-value="i+1"/>
- </t>
- </div>
- </t>
-
<t t-name="website.course.player.infos">
<div class="text-box">
<h1><t t-esc="slide.name"/></h1>
@@ -67,32 +23,6 @@
</div>
</t>
- <t t-name="website.course.quiz.success">
- <div class="o_wslides_quiz_modal_background">
- </div>
- <div class="o_wslides_quiz_success_modal">
- <div class="o_wslides_quiz_success_modal_left_panel"></div>
- <div class="o_wslides_quiz_success_modal_right_panel">
- <div class="o_wslides_quiz_success_modal_close"><i class="fa fa-times"></i></div>
- <h1>Amazing!</h1>
- <div class="o_wslides_quiz_success_xp">
- <p>You gained <span t-esc="data.points"/> points!</p>
- <div class="o_wslides_quiz_success_progress_bar">
- <div class="o_wslides_quiz_success_progress_gauge" t-att-style="'width:'+data.user.progress_bar_percentage+'%'"/>
- </div>
- <div class="o_wslides_quiz_success_progress_bounds">
- <span t-esc="data.user.lower_bound"/>
- <span t-esc="data.user.upper_bound"/>
- </div>
- </div>
- <div class="o_wslides_quiz_success_reward"></div>
- <div t-if="nextSlide" class="o_wslides_quiz_success_button">
- <a class="next-slide">Next &gt;</a>
- </div>
- </div>
- </div>
- </t>
-
<t t-name="website.course.fullscreen.title">
<t t-if="!miniQuiz">
<i t-if="slide.slide_type == 'document'" class="fa fa-file-pdf-o mr-2"></i>
diff --git a/addons/website_slides/views/assets.xml b/addons/website_slides/views/assets.xml
index eb000518491..b4aa5dff9d2 100644
--- a/addons/website_slides/views/assets.xml
+++ b/addons/website_slides/views/assets.xml
@@ -6,9 +6,7 @@
<xpath expr="//link[last()]" position="after">
<link rel="stylesheet" type="text/scss" href="/website_slides/static/src/scss/website_slides.scss" t-ignore="true"/>
<link rel="stylesheet" type="text/scss" href="/website_slides/static/src/scss/website_slides_profile.scss"/>
- <link rel="stylesheet" type="text/scss" href="/website_slides/static/src/scss/slide_slide.scss" t-ignore="true"/>
<link rel="stylesheet" type="text/scss" href="/website_slides/static/src/scss/slides_slide_fullscreen.scss" t-ignore="true"/>
- <link rel="stylesheet" type="text/scss" href="/website_slides/static/src/scss/website_slides_quiz.scss"/>
</xpath>
<xpath expr="//script[last()]" position="after">
<script type="text/javascript" src="/website_slides/static/src/js/slides.js"/>
diff --git a/addons/website_slides/views/website_slides_templates_course.xml b/addons/website_slides/views/website_slides_templates_course.xml
index ec52bb3961a..ccc215ebf4f 100644
--- a/addons/website_slides/views/website_slides_templates_course.xml
+++ b/addons/website_slides/views/website_slides_templates_course.xml
@@ -29,7 +29,7 @@
</li>
</ol>
</nav>
- <div class="col-md-6 col-xs-12 d-flex flex-row align-items-center">
+ <div class="col-md-6 col-xs-12 d-flex flex-row align-items-center justify-content-end">
<!-- karma / profile -->
<a t-if="not is_public_user" t-att-href="'/profile/user/%s?channel_id=%s' % (user.id, channel.id)" class="font-weight-bold mr-3">
<i class="fa fa-diamond" /> <t t-esc="user_id.karma"/>
@@ -264,39 +264,45 @@
<div class="row">
<t t-call="website_slides.course_sidebar"/>
<div class="col-lg-8 mt-3 mb-5 o_wslides_slides_list" t-att-data-channel-id="channel.id">
- <ul class="o_wslides_slides_list_container">
+ <ul class="o_wslides_js_slides_list_container border-bottom list-unstyled">
<t t-set="j" t-value="0"/>
- <li t-if="uncategorized_slides or channel.can_publish">
- <div class="text-muted font-weight-bold pt-0 pb-0 pl-2 pr-2 d-flex justify-content-between align-items-center">
- <t t-call="website_slides.course_slides_list_category"/>
- </div>
- <ul>
- <li t-att-class="('text-muted font-italic o_wslides_slides_list_slide o_wslides_empty_category justify-content-between align-items-center p-2 %s') % ('d-none' if uncategorized_slides else 'd-flex')">
- <div class="content-slide-infos ml-2">
- <span>Empty category</span>
+ <t t-foreach="category_data" t-as="category">
+ <t t-set="category_record" t-value="category['category'] if category['id'] else None"/>
+ <t t-set="category_id" t-value="category['id'] if category['id'] else None"/>
+ <li t-if="category['total_slides'] or channel.can_publish" class="o_wslides_slide_list_category"
+ t-att-data-category-id="category_id">
+ <div t-att-data-category-id="category_id"
+ t-att-class="'text-muted bg-white border-bottom font-weight-bold pt-0 pb-0 pl-2 pr-2 d-flex justify-content-between align-items-center %s' % ('o_wslides_js_category' if category_id else '')">
+ <div class="pt-2 pb-2 d-flex align-items-center">
+ <i t-if="channel.can_publish and category_id" class="fa fa-arrows mr-3"></i>
+ <t t-if="category_record">
+ <span t-field="category_record.name"/>
+ </t>
+ <t t-else="">
+ <span>Uncategorized</span>
+ </t>
</div>
- </li>
- <li class="o_wslides_slides_list_slide o_not_editable"></li>
- <t t-foreach="uncategorized_slides" t-as="slide">
- <t t-call="website_slides.course_slides_list_slide" />
- <t t-set="j" t-value="j+1"/>
- </t>
- </ul>
- </li>
- <t t-foreach="channel.category_ids" t-as="category">
- <li t-if="category.slide_ids or channel.can_publish" class="o_wslides_slide_list_category" t-att-data-category-id="category.id">
- <div t-att-data-category-id="category.id"
- class="o_wslides_js_category text-muted font-weight-bold pt-0 pb-0 pl-2 pr-2 d-flex justify-content-between align-items-center">
- <t t-call="website_slides.course_slides_list_category" />
+ <a t-if="channel.can_upload"
+ class="mr-2 pt-2 pb-2 border-left o_wslides_js_slide_upload"
+ role="button"
+ aria-label="Upload Presentation"
+ href="#"
+ style="text-decoration: none;"
+ t-att-data-channel-id="channel.id"
+ t-att-data-category-id="category_id"
+ t-att-data-can-upload="channel.can_upload"
+ t-att-data-can-publish="channel.can_publish">
+ <i class="fa fa-plus ml-2"></i> Add content
+ </a>
</div>
- <ul t-att-data-category-id="category.id">
- <li t-att-class="('text-muted font-italic o_wslides_slides_list_slide o_wslides_empty_category justify-content-between align-items-center p-2 %s') % ('d-none' if category.slide_ids else 'd-flex')">
- <div class="content-slide-infos ml-2">
+ <ul t-att-data-category-id="category_id" class="list-unstyled">
+ <li t-att-class="('text-muted font-italic o_wslides_slides_list_slide o_wslides_js_slides_list_empty justify-content-between align-items-center p-2 %s') % ('d-none' if category['total_slides'] else 'd-flex')">
+ <div class="ml-2">
<span>Empty category</span>
</div>
</li>
<li class="o_wslides_slides_list_slide o_not_editable"></li>
- <t t-foreach="category.slide_ids" t-as="slide">
+ <t t-foreach="category['slides']" t-as="slide">
<t t-call="website_slides.course_slides_list_slide" />
<t t-set="j" t-value="j+1"/>
</t>
@@ -305,15 +311,16 @@
</t>
</ul>
<div t-if="channel.can_upload" class="o_wslides_content_actions">
- <a class="mr-2 o_wslides_js_slide_upload"
+ <a class="o_wslides_js_slide_upload mr-2 border btn btn-light text-muted font-weight-bold"
role="button"
aria-label="Upload Presentation"
href="#"
t-att-data-channel-id="channel.id"
t-att-data-can-upload="channel.can_upload"
- t-att-data-can-publish="channel.can_publish">Add Content</a>
- <a class="o_wslides_js_slide_section_add" t-attf-channel_id="#{channel.id}" href="#"
- groups="website.group_website_publisher">Add Section</a>
+ t-att-data-can-publish="channel.can_publish"><i class="fa fa-plus"></i> Add Content</a>
+ <a class="o_wslides_js_slide_section_add border btn btn-light text-muted font-weight-bold" t-attf-channel_id="#{channel.id}"
+ href="#" role="button"
+ groups="website.group_website_publisher"><i class="fa fa-folder-o"></i> Add Section</a>
</div>
</div>
<div t-field="channel.description_html"/>
@@ -321,58 +328,55 @@
</template>
<template id="course_slides_list_slide" name="Slide template for a training channel">
- <li t-att-index="j" t-att-data-slide-id="slide.id" t-att-data-category-id="category.id if category else None"
- class="o_wslides_slides_list_slide d-flex justify-content-between align-items-center p-2">
- <div class="ml-2">
- <i t-if="channel.can_publish" class="fa fa-arrows mr-2 text-muted"></i>
- <t t-call="website_slides.slide_icon"/>
- <a class="o_wslides_slides_list_slide_link" t-attf-href="/slides/slide/#{slug(slide)}"><span t-field="slide.name"/></a>
+ <li t-att-index="j" t-att-data-slide-id="slide.id" t-att-data-category-id="category_id"
+ class="o_wslides_slides_list_slide d-flex align-items-center px-2 py-0">
+ <div t-if="channel.can_publish" class="border-right pt-2 pb-2">
+ <i class="fa fa-arrows mr-2 text-muted"></i>
</div>
- <div class="o_wslides_slides_list_slide_controls mr-2">
- <i t-if="not slide.id in user_progress or not user_progress[slide.id].completed" class="check-done fa fa-check-circle text-muted mr-1"></i>
- <i t-if="slide.id in user_progress and user_progress[slide.id].completed" class="check-done text-primary fa fa-check-circle mr-1"></i>
+ <a t-if="slide.is_preview or channel.is_member or channel.can_publish" class="o_wslides_js_slides_list_slide_link font-weight-bold mr-auto"
+ t-attf-href="/slides/slide/#{slug(slide)}">
+ <t t-call="website_slides.slide_icon">
+ <t t-set="icon_class" t-value="'pt-2 pb-2 ml-2 mr-2'"/>
+ </t>
+ <span t-field="slide.name"/>
+ </a>
+ <span t-else="" class="text-muted mr-auto">
+ <t t-call="website_slides.slide_icon">
+ <t t-set="icon_class" t-value="'pt-2 pb-2 ml-2 mr-2'"/>
+ </t>
+ <span t-esc="slide.name"/>
+ </span>
+ <t t-if="slide.question_ids">
+ <span t-att-class="'badge text-white font-weight-bold %s' % ('badge-success' if channel_progress[slide.id].get('completed') else 'badge-warning')">
+ <i class="fa fa-flag mr-2"></i>
+ <t t-esc="channel_progress[slide.id].get('quiz_karma_won', 0) if channel_progress[slide.id].get('completed') else channel_progress[slide.id].get('quiz_karma_gain', 0)"/> xp
+ </span>
+ </t>
+ <span class="badge badge-danger ml-2" t-if="not slide.website_published">Unpublished</span>
+ <span class="badge badge-info ml-2" t-if="slide.is_preview">Free preview</span>
+ <div t-if="channel.is_member or channel.can_publish" class="pt-2 pb-2 border-left ml-2 mr-2 pl-2 d-flex align-items-center o_wslides_slides_list_slide_controls">
+ <t t-if="channel.is_member">
+ <i t-if="not channel_progress[slide.id].get('completed')" class="check-done fa fa-check-circle text-muted mr-2"></i>
+ <i t-if="channel_progress[slide.id].get('completed')" class="check-done text-primary fa fa-check-circle mr-2"></i>
+ </t>
<a t-if="channel.can_publish and not slide.slide_type == 'webpage'" t-attf-href="/web#id=#{slide.id}&amp;action=#{slide_action}&amp;model=slide.slide&amp;view_type=form">
- <i class="fa fa-pencil text-muted mr-1"></i>
+ <i class="fa fa-pencil text-muted mr-2"></i>
</a>
<a t-if="channel.can_publish and slide.slide_type == 'webpage'" t-attf-href="/slides/slide/#{slug(slide)}?enable_editor=1">
- <i class="fa fa-pencil text-muted mr-1"></i>
+ <i class="fa fa-pencil text-muted mr-2"></i>
</a>
<i t-if="channel.can_publish" t-att-data-slide-id="slide.id" class="fa fa-trash text-muted o_wslides_js_slide_archive"></i>
</div>
</li>
</template>
-<template id="course_slides_list_category" name="Category template for a training channel">
- <div style="width:50%;" class="d-flex align-items-center">
- <i t-if="channel.can_publish and category" class="fa fa-arrows mr-3 text-muted"></i>
- <t t-if="category">
- <span t-field="category.name"/>
- </t>
- <t t-else="">
- <span>Uncategorized</span>
- </t>
- </div>
- <a t-if="channel.can_upload"
- class="mr-2 o_wslides_js_slide_upload"
- role="button"
- aria-label="Upload Presentation"
- href="#"
- style="font-size: 1.5rem;text-decoration: none;"
- t-att-data-channel-id="channel.id"
- t-att-data-category-id="category.id if category else None"
- t-att-data-can-upload="channel.can_upload"
- t-att-data-can-publish="channel.can_publish">+</a>
-</template>
-
<template id="course_slides_cards" name="Documentation Course content: cards / categories">
<div t-if="not search" class="row mb-5">
<t t-call="website_slides.course_sidebar"/>
<div class="col-lg-8">
- <div class="row align-items-center mt-3">
- <div class="col">
- <h5 class="m-0"> Featured lesson</h5>
- <hr class="mt-2"/>
- </div>
+ <div class="mt-3 w-100">
+ <h5 class="m-0"> Featured lesson</h5>
+ <hr class="mt-2"/>
</div>
<div class="row" t-if="slide_promoted">
<div class="col-4">
@@ -381,17 +385,15 @@
t-att-alt="slide_promoted.name"/>
</div>
<div class="col-8">
- <div class="row">
- <h3 class="col-6" t-att-title="slide_promoted.name">
- <t t-if="slide_promoted.is_preview or channel.is_member or is_slides_publisher">
- <a t-attf-href="/slides/slide/#{slug(slide_promoted)}" class="font-weight-bold text-muted" t-field="slide_promoted.name"/>
- </t>
- <t t-else="">
- <span class="font-weight-bold text-muted" t-field="slide_promoted.name"/>
- </t>
- </h3>
- <div class="col-12" t-field="slide_promoted.description"/>
- </div>
+ <h3 class="w-100" t-att-title="slide_promoted.name">
+ <t t-if="slide_promoted.is_preview or channel.is_member or is_slides_publisher">
+ <a t-attf-href="/slides/slide/#{slug(slide_promoted)}" class="font-weight-bold text-muted" t-field="slide_promoted.name"/>
+ </t>
+ <t t-else="">
+ <span class="font-weight-bold text-muted" t-field="slide_promoted.name"/>
+ </t>
+ </h3>
+ <div t-field="slide_promoted.description"/>
</div>
</div>
</div>
@@ -425,6 +427,11 @@
<div class="row">
<div class="col" t-field="channel.description_html"/>
</div>
+ <t t-if="search_category">
+ <div class="form-inline justify-content-center">
+ <t t-call="website_profile.pager_nobox"></t>
+ </div>
+ </t>
</template>
<template id='lesson_card' name="Lesson Card">
@@ -470,7 +477,7 @@
<span t-esc="slide.dislikes"/>
</span>
</div>
- <t t-if="channel.is_member and slide.id in done_slide_ids">
+ <t t-if="channel.is_member and channel_progress[slide.id].get('completed')">
<span class="badge badge-pill badge-success"><i class="fa fa-check"/> Completed</span>
</t>
</div>
@@ -479,12 +486,14 @@
</template>
<template id="slide_icon">
- <i t-if="slide.slide_type == 'document'" class="fa fa-file-pdf-o mr-2 text-muted"></i>
- <i t-if="slide.slide_type == 'infographic'" class="fa fa-file-picture-o mr-2 text-muted"></i>
- <i t-if="slide.slide_type == 'video'" class="fa fa-play-circle mr-2 text-muted"></i>
- <i t-if="slide.slide_type == 'link'" class="fa fa-file-code-o mr-2 text-muted"></i>
- <i t-if="slide.slide_type == 'webpage'" class="fa fa-file-text mr-2 text-muted"></i>
- <i t-if="slide.slide_type == 'quiz'" class="fa fa-question-circle mr-2 text-muted"></i>
+ <t t-set="icon_class" t-value="icon_class if icon_class else 'mr-2 text-muted'"/>
+ <i t-if="slide.slide_type == 'document'" t-att-class="'fa fa-file-pdf-o %s' % icon_class"></i>
+ <i t-if="slide.slide_type == 'presentation'" t-att-class="'fa fa-file-pdf-o %s' % icon_class"></i>
+ <i t-if="slide.slide_type == 'infographic'" t-att-class="'fa fa-file-picture-o %s' % icon_class"></i>
+ <i t-if="slide.slide_type == 'video'" t-att-class="'fa fa-play-circle %s' % icon_class"></i>
+ <i t-if="slide.slide_type == 'link'" t-att-class="'fa fa-file-code-o %s' % icon_class"></i>
+ <i t-if="slide.slide_type == 'webpage'" t-att-class="'fa fa-file-text %s' % icon_class"></i>
+ <i t-if="slide.slide_type == 'quiz'" t-att-class="'fa fa-question-circle %s' % icon_class"></i>
</template>
</data></odoo>
diff --git a/addons/website_slides/views/website_slides_templates_homepage.xml b/addons/website_slides/views/website_slides_templates_homepage.xml
index 145120c2b5d..108221a7ab0 100644
--- a/addons/website_slides/views/website_slides_templates_homepage.xml
+++ b/addons/website_slides/views/website_slides_templates_homepage.xml
@@ -44,7 +44,46 @@
</div>
<div class="container o_wslides_home_main">
<div class="row">
- <div class="col-lg-9 pr-lg-5">
+ <div t-if="is_public_user" class="col-lg-3 order-3 order-lg-2">
+ <div class="row">
+ <div class="col-12 col-md-7 col-lg-12">
+ <t t-call="website_slides.slides_home_achievements_small"/>
+ </div>
+ <div class="col-12 col-md-5 col-lg-12 mt-5 mt-md-0">
+ <div class="pl-md-5 pl-lg-0 mt-lg-4">
+ <t t-call="website_slides.slides_home_users_small"/>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div t-else="" class="col-lg-3 order-lg-2">
+ <div class="o_wslides_home_aside_loggedin card p-3 p-lg-0 mb-4">
+ <div class="o_wslides_home_aside_title">
+ <div class="d-flex align-items-center">
+ <t t-call="website_slides.slides_misc_user_image">
+ <t t-set="img_class" t-value="'rounded-circle mr-1'"/>
+ <t t-set="img_style" t-value="'height: 22px'"/>
+ </t>
+ <h5 t-esc="user.name" class="d-flex flex-grow-1 mb-0"/>
+ <a class="d-none d-lg-block" t-att-href="'/profile/user/%s' % user.id">Edit</a>
+ <a class="d-lg-none btn btn-sm bg-white border" href="#" data-toggle="collapse" data-target="#o_wslides_home_aside_content">More info</a>
+ </div>
+ <hr class="d-none d-lg-block mt-2"/>
+ </div>
+ <div id="o_wslides_home_aside_content" class="collapse d-lg-block">
+ <div class="row mb-5 mt-3 mt-lg-0">
+ <div class="col-12 col-sm-6 col-lg-12">
+ <t t-call="website_slides.slides_home_user_profile_small"/>
+ <hr class="d-sm-none d-lg-block" />
+ </div>
+ <div class="col-12 col-sm-6 col-lg-12 d-flex align-items-center">
+ <t t-call="website_slides.slides_home_user_achievements_small"/>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="col-lg-9 pr-lg-5 order-lg-1">
<t t-if="channels_my">
<t t-set="void_count" t-value="3 - len(channels_my[:3])"/>
<div class="o_wslides_home_content_section mb-5">
@@ -91,31 +130,12 @@
</div>
</div>
</div>
- <div class="col-lg-3">
- <t t-if="is_public_user">
- <t t-call="website_slides.slides_home_achievements_small"/>
- <t t-call="website_slides.slides_home_users_small"/>
- </t>
- <t t-else="">
- <t t-call="website_slides.slides_home_user_profile_small"/>
- <hr />
- <t t-call="website_slides.slides_home_user_achievements_small"/>
- </t>
- </div>
</div>
</div>
- <section class="s_banner bg-900" style="background-image: url(&quot;/website/static/src/img/backgrounds/people.jpg&quot;); background-size: cover; background-position: 65% 55%">
+ <section class="s_banner" style="background-image: url(/website_slides/static/src/img/banner_heroes_default.svg); background-size: cover; background-position: right bottom">
<div class="container py-5">
- <h1 class="display-3 mb-0">Meet the people who transformed their company</h1>
- <h2 class="mb-4">Nobody gave me a real text so instead of a lorem ipsum I put that one.</h2>
- <div class="row mt-1 mb-3">
- <div class="col-lg-4">
- <p>Nobody gave me a real text so instead of a lorem ipsum I put that one.</p>
- </div>
- <div class="col-lg-3">
- <a href="/profile/users" class="btn btn-primary btn-lg">Go meet them</a>
- </div>
- </div>
+ <h2 class="text-white mb-4">Meet the people who transformed their company</h2>
+ <a href="/profile/users" class="btn btn-primary btn-lg">Go meet them</a>
</div>
</section>
</div>
@@ -128,8 +148,8 @@
<div class="wrap o_wslides_wrap">
<section class="s_banner bg-900" style="background-image: url(&quot;/website_slides/static/src/img/banner_default_all.svg&quot;); background-size: cover; background-position: 80% 20%">
<div class="container py-5">
- <h1 class="display-3 mb-0"><t t-if="search_my">My Courses</t><t t-else="">All Courses</t></h1>
- <h3 class="mb-4">Start your online course today !</h3>
+ <h1 t-if="search_my" class="display-3 mb-0">My Courses</h1>
+ <h1 t-else="" class="display-3 mb-0">All Courses</h1>
</div>
</section>
<div class="container mt16 o_wslides_home_nav position-relative">
@@ -163,18 +183,11 @@
</div>
</li>
</t>
- <!-- karma / profile -->
- <li class="nav-item ml-auto" t-if="not is_public_user">
- <a t-att-href="'/profile/user/%s' % user.id" class="nav-link mr-2"
- role="button" title="My Profile">
- <i class="fa fa-diamond"/> <t t-esc="user_id.karma"/>
- </a>
- </li>
<!-- clear filtering -->
- <li class="nav-item" t-if="search_slide_type or search_my or search_tags or search_channel_tag_id">
+ <li class="nav-item ml-auto" t-if="search_slide_type or search_my or search_tags or search_channel_tag_id">
<a t-att-href="'/slides/all?%s' % keep_query('debug')" class="nav-link mr-2"
role="button" title="Clear filters">
- <i class="fa fa-eraser"/>
+ <i class="fa fa-eraser"/> Clear filters
</a>
</li>
</ul>
@@ -241,15 +254,17 @@
</div>
</div>
<div class="card-footer border-top-0 text-600">
- <div class="d-flex justify-content-between align-items-center small">
- <span class="font-weight-bold" t-field="channel.total_time" t-options='{"widget": "float_time"}'/>
- <t t-if="channel.is_member and channel.completed">
- <span class="badge badge-pill badge-success pull-right"><i class="fa fa-check"/> Completed</span>
- </t>
- <div t-elif="channel.is_member" class="progress w-50">
- <div class="progress-bar" role="progressbar" t-att-aria-valuenow="channel.completion" aria-valuemin="0" aria-valuemax="100" t-attf-style="width:#{channel.completion}%;"/>
+ <div class="d-flex justify-content-between align-items-center">
+ <small class="font-weight-bold" t-field="channel.total_time" t-options='{"widget": "float_time"}'/>
+ <div class="d-flex flex-grow-1 justify-content-end">
+ <t t-if="channel.is_member and channel.completed">
+ <span class="badge badge-pill badge-success pull-right py-1 px-2"><i class="fa fa-check"/> Completed</span>
+ </t>
+ <div t-elif="channel.is_member" class="progress w-50" style="height: 4px;">
+ <div class="progress-bar rounded" role="progressbar" t-att-aria-valuenow="channel.completion" aria-valuemin="0" aria-valuemax="100" t-attf-style="width:#{channel.completion}%;"/>
+ </div>
+ <small t-else=""><b t-esc="channel.total_slides"/> steps</small>
</div>
- <t t-else=""><span class="font-weight-bold" t-esc="channel.total_slides"/> steps</t>
</div>
</div>
</div>
@@ -278,13 +293,11 @@
</template>
<template id='achievement_card' name="Achivement Card">
- <div class="row no-gutters mt8 align-items-center">
- <div class="col-2">
- <t t-call="website_slides.slides_misc_user_image">
- <t t-set="user" t-value="achievement.user_id"/>
- </t>
- </div>
- <div class="col">
+ <div class="d-flex no-gutters mt8 align-items-center">
+ <t t-call="website_slides.slides_misc_user_image">
+ <t t-set="user" t-value="achievement.user_id"/>
+ </t>
+ <div style="line-height: 1.3">
<span class="font-weight-bold" t-esc="achievement.user_id.name"/> achieved <a href="/slides"><span t-esc="achievement.badge_id.name"/></a>
</div>
</div>
@@ -315,14 +328,10 @@
</template>
<template id='user_quickkarma_card' name="User QuickKarma Card">
- <div class="row no-gutters mt8 align-items-center">
- <div class="col-2">
- <span class="font-weight-bold text-muted o_wslides_home_circled" t-esc="counter"/>
- </div>
- <div class="col-2">
- <t t-call="website_slides.slides_misc_user_image"/>
- </div>
- <div class="col">
+ <div class="d-flex mb-3 align-items-center">
+ <b class="mr-2 text-muted" t-esc="counter"/>
+ <t t-call="website_slides.slides_misc_user_image"/>
+ <div style="line-height:1.3">
<span class="font-weight-bold" t-esc="user.name"/>
<div class="d-flex align-items-center">
<span class="text-muted" t-esc="user.rank_id.name"/><span class="ml8 mr8">&#8226;</span><span t-esc="user.karma" class="badge badge-success"/>
@@ -333,38 +342,27 @@
<template id='slides_home_user_profile_small' name="User Profile">
<div class="o_wslides_home_aside">
- <div class="o_wslides_home_aside_title">
- <div class="d-flex align-items-center">
- <t t-call="website_slides.slides_misc_user_image">
- <t t-set="img_class" t-value="'rounded-circle mr-1'"/>
- <t t-set="img_style" t-value="'height: 22px'"/>
- </t>
- <h5 t-esc="user.name" class="d-flex flex-grow-1 mb-0"/>
- <a t-att-href="'/profile/user/%s' % user.id">Edit</a>
- </div>
+ <div t-if="user.rank_id" class="d-flex align-items-center">
+ <span class="font-weight-bold text-muted mr-2">Current rank:</span>
+ <img t-attf-src="/web/image/gamification.karma.rank/#{user.rank_id.id}/image_small" height="16" alt="" class="mr-1"/>
+ <a href="/profile/ranks" t-field="user.rank_id"/>
</div>
- <hr class="mt-2"/>
- <div class="mt8">
- <div class="d-flex align-items-center">
- <span class="font-weight-bold text-muted mr-2">Current rank:</span>
- <img t-attf-src="/web/image/gamification.karma.rank/#{user.rank_id.id}/image_small" height="16" alt="" class="mr-1"/>
- <a href="/profile/ranks" t-field="user.rank_id"/>
- </div>
+ <t t-if="user.next_rank_id">
<div class="font-weight-bold text-muted mt-1">Next rank:</div>
<t t-call="website_profile.profile_next_rank_card">
<t t-set="bg_class">bg-200</t>
<t t-set="img_max_width">50%</t>
</t>
<div t-field="user.next_rank_id.description_motivational"/>
- </div>
+ </t>
</div>
</template>
<template id='slides_home_user_achievements_small' name="User Achievements">
- <div class="o_wslides_home_aside mb-2">
+ <div class="o_wslides_home_aside flex-grow-1 mb-2">
<t t-foreach="challenges" t-as="challenge">
<t t-set="challenge_done" t-value="challenge in challenges_done if challenges_done else False"/>
- <div t-attf-class="d-flex mt-3 align-items-center #{'o_wslides_entry_muted' if not challenge_done else ''}">
+ <div t-attf-class="d-flex mb-3 align-items-center #{'o_wslides_entry_muted' if not challenge_done else ''}">
<img class="mr-2"
style="max-height: 36px;"
t-att-src="'/web/image/gamification.badge/%s/image_medium' % challenge.reward_id.id"
@@ -373,7 +371,7 @@
<b class="text_small_caps" t-esc="challenge.reward_id.name"/><br/>
<span class="text-muted" t-esc="challenge.reward_id.description"/>
</div>
- <i t-if="challenge_done" class="fa fa-check" aria-label="Done" title="Done" role="img"></i>
+ <i t-if="challenge_done" class="fa fa-check h5 text-success" aria-label="Done" title="Done" role="img"></i>
</div>
</t>
</div>
diff --git a/addons/website_slides/views/website_slides_templates_lesson.xml b/addons/website_slides/views/website_slides_templates_lesson.xml
index 98f7577b848..67338fbe190 100644
--- a/addons/website_slides/views/website_slides_templates_lesson.xml
+++ b/addons/website_slides/views/website_slides_templates_lesson.xml
@@ -2,477 +2,420 @@
<odoo><data>
<!-- Slide: main template: detailed view -->
-<template id="slide_detail_view" name="Slide Detailed View">
+<template id="slide_main" name="Slide Detailed View">
<t t-call="website.layout">
<t t-set="main_object" t-value="slide"/>
+ <div class="wrap o_wslides_wrap">
<t t-call="website_slides.course_nav">
<t t-set="channel" t-value="slide.channel_id"/>
</t>
- <div class="o_wslides_slide_header">
- <div class="o_wslides_slide_header_container">
- <div class="o_wslides_slide_header_box">
- <a t-attf-href="/slides/#{slug(slide.channel_id)}" t-field="slide.channel_id.name"/>
-
- <div t-if="slide.channel_id.channel_type == 'training'" class="d-flex align-items-end">
- <div class="progress">
- <div class="progress-bar" role="progressbar"
- t-att-aria-valuenow="slide.channel_id.completion" aria-valuemin="0" aria-valuemax="100"
- t-att-style="'width: %s' % (slide.channel_id.completion)"/>
+ <div class="o_wslides_lesson_header">
+ <div class="container o_wslides_lesson_header_container pt-3 pb-3">
+ <div class="row align-items-end d-flex">
+ <div class="col-md-9 offset-md-3 col-xs-12">
+ <h3 class="font-weight-bold w-100">
+ <a t-att-href="'/slides/%s' % (slug(slide.channel_id))"
+ class="text-white"
+ t-field="slide.channel_id.name"/>
+ </h3>
+ <div class="d-flex align-items-center">
+ <div class="progress w-50" style="height: 8px;">
+ <div class="progress-bar rounded" role="progressbar"
+ t-att-aria-valuenow="slide.channel_id.completion" aria-valuemin="0" aria-valuemax="100"
+ t-attf-style="width: #{slide.channel_id.completion}%;">
+ <span><t t-esc="slide.channel_id.completion"/> %</span>
+ </div>
+ </div>
+ <i t-att-class="'fa fa-trophy m-0 ml-2 p-0 %s' % ('text-success' if slide.channel_id.completed else 'text-warning')"></i>
</div>
- <i class="fa fa-trophy ml-2 mb-0 p-0" style="font-size:1.8rem; color: #cdcdcd;"></i>
</div>
</div>
</div>
</div>
- <div t-if="slide.channel_id.channel_type == 'training'" class="o_wslides_slide_content_list">
- <ul class="o_wslides_slide_list">
- <li>
- <div class="o_wslides_slide_content_list_header">
- Course content
- </div>
- </li>
- <t t-set="i" t-value="0"/>
- <t t-if="uncategorized_slides" t-call="website_slides.slide_detail_list_category_view">
- <t t-set="category_slide_ids" t-value="uncategorized_slides" />
+ <div class="container o_wslides_lesson_main">
+ <div class="row">
+ <div class="col-lg-3 o_wslides_lesson_aside">
+ <t t-if="slide.channel_id.channel_type == 'training'"
+ t-call="website_slides.slide_aside_training"/>
+ <t t-if="slide.channel_id.channel_type == 'documentation'"
+ t-call="website_slides.slide_aside_documentation"/>
+ </div>
+ <div class="col-lg-9 o_wslides_lesson_content">
+ <t t-call="website_slides.slide_content_detailed"/>
+ </div>
+ </div>
+ </div>
+ </div>
+ </t>
+</template>
+
+<!-- Slide: sidebar documentation mode -->
+<template id="slide_aside_documentation" name="Slide: Sidebar in Documentation">
+ <div class="o_wslides_lesson_aside_doc bg-white">
+ <ul class="nav nav-tabs" role="tablist">
+ <li class="nav-item"><a aria-controls="related" href="#related" class="nav-link active" data-toggle="tab">Related</a></li>
+ <li class="nav-item"><a aria-controls="most_viewed" href="#most_viewed" class="nav-link" data-toggle="tab">Most Viewed</a></li>
+ </ul>
+ <div class="tab-content">
+ <div role="tabpanel" id="related" class="tab-pane active">
+ <ul class="list-unstyled card-body px-2 py-0">
+ <t t-set="related_slides_list" t-value="list(related_slides)"/>
+ <t t-if="not related_slides_list">
+ No presentation available.
</t>
- <t t-foreach="slide.channel_id.category_ids" t-as="category">
- <t t-call="website_slides.slide_detail_list_category_view">
- <t t-set="category_slide_ids" t-value="category.slide_ids"/>
- </t>
+ <t t-else="" t-foreach="related_slides_list" t-as="aside_slide">
+ <t t-call="website_slides.slide_aside_card"/>
</t>
</ul>
</div>
- <div class="o_wslides_slide_header_container d-flex justify-content-between mt-4 mb-3">
- <div class="o_wslides_slide_title">
- <h1 t-field="slide.name"/>
- <span t-if="slide.question_ids" t-attf-class="o_wslides_slide_points ml-2 #{'quiz-points-won' if slide.id in user_progress and user_progress[slide.id].completed else ''}">
- <span t-if="slide.id in user_progress and user_progress[slide.id].quiz_attempts_count == 0" id="quiz-points" t-esc="slide.quiz_first_attempt_reward"/>
- <span t-if="slide.id in user_progress and user_progress[slide.id].quiz_attempts_count == 1" id="quiz-points" t-esc="slide.quiz_second_attempt_reward"/>
- <span t-if="slide.id in user_progress and user_progress[slide.id].quiz_attempts_count == 2" id="quiz-points" t-esc="slide.quiz_third_attempt_reward"/>
- <span t-if="slide.id in user_progress and user_progress[slide.id].quiz_attempts_count > 2" id="quiz-points" t-esc="slide.quiz_fourth_attempt_reward"/>
- points
- </span>
- </div>
- <div>
- <t t-if="previous_slide">
- <a class="o_wslides_slide_button" t-attf-href="/slides/slide/#{previous_slide}">Prev</a>
+ <div role="tabpanel" id="most_viewed" class="tab-pane">
+ <ul class="list-unstyled card-body px-2 py-0">
+ <t t-set="most_viewed_slides_list" t-value="list(most_viewed_slides)"/>
+ <t t-if="not list(most_viewed_slides_list)">
+ No presentation available.
</t>
- <a t-if="slide.slide_type in ('webpage', 'video', 'document', 'iconographic') and not slide.question_ids and (slide.id in user_progress and not user_progress[slide.id].completed)"
- t-att-href="'/slides/slide/%s/set_completed?%s' % (slide.id, 'next_slide=%s' % next_slide if next_slide else '')"
- class="o_wslides_slide_button_done">Set Done</a>
- <t t-if="next_slide">
- <a class="o_wslides_slide_button" t-attf-href="/slides/slide/#{next_slide}">Next</a>
+ <t t-else="" t-foreach="most_viewed_slides_list" t-as="aside_slide">
+ <t t-call="website_slides.slide_aside_card"/>
</t>
- <a t-if="slide.channel_id.channel_type == 'training'" t-attf-href="/slides/slide/#{slug(slide)}?fullscreen=1" class="o_wslides_slide_button_fullscreen ml-2"><i class="fa fa-desktop mr-2"></i>fullscreen</a>
- </div>
+ </ul>
</div>
- <div class="o_wslides_slide_header_container mt16">
- <div class="row">
- <div style="width:100%;">
- <div class="o_w_slides_slide_detail_container" style="width: 100%;">
- <t t-if="slide.slide_type == 'infographic'">
- <img t-attf-src="/web/image/slide.slide/#{slide.id}/image" class="img-fluid" style="width:100%" alt="Slide image"/>
- </t>
- <div style="height: 600px;" t-if="slide.slide_type in ('presentation', 'document')" class="embed-responsive embed-responsive-4by3 embed-responsive-item mb8">
- <t t-raw="slide.embed_code"/>
- </div>
- <div t-if="slide.slide_type == 'video' and slide.document_id" class="embed-responsive embed-responsive-16by9 embed-responsive-item mb8">
- <t t-raw="slide.embed_code"/>
- </div>
- <div t-if="slide.slide_type == 'webpage'" class="border border-light rounded">
- <div t-field="slide.html_content"/>
- </div>
- <div t-if="slide.question_ids">
- <div class=".o_wslides_fs_quiz o_w_slides_quiz_no_fullscreen mt-2"
- t-attf-slide_id="#{slide.id}"
- t-attf-slide_done="#{slide.id in user_progress and user_progress[slide.id].completed}"
- t-attf-nb_attempts="#{user_progress[slide.id].quiz_attempts_count if slide.id in user_progress else ''}"
- t-attf-first_reward="#{slide.quiz_first_attempt_reward}"
- t-attf-second_reward="#{slide.quiz_second_attempt_reward}"
- t-attf-third_reward="#{slide.quiz_third_attempt_reward}"
- t-attf-fourth_reward="#{slide.quiz_fourth_attempt_reward}"
- t-if="slide.question_ids">
- <div>
- <t t-set="i" t-value="1"/>
- <t t-foreach="slide.question_ids" t-as="question">
- <t t-set="j" t-value="0"/>
- <div class="o_wslides_quiz_question" t-attf-id="#{question.id}" t-attf-title="#{question.question}">
- <div>
- <div class="o_wslides_quiz_question_title">
- <span class="o_wslides_quiz_question_title_number"><span t-esc="i"/>. </span>
- <span class="o_wslides_quiz_question_title_text" t-esc="question.question"/>
- </div>
- <ul class="o_wslides_quiz_question_answers">
- <t t-foreach="question.answer_ids" t-as="answer">
- <li t-attf-id="answer#{answer.id}" t-att-question_id="question.id" t-attf-text_value="#{answer.text_value}" t-attf-class="#{'o_wslides_quiz_answer o_wslides_quiz_good_answer' if slide.id in user_progress and user_progress[slide.id].completed and answer.is_correct else 'o_wslides_quiz_answer o_wslides_quiz_ignored_answer' if slide.id in user_progress and user_progress[slide.id].completed else 'o_wslides_quiz_answer'}">
- <label class="o_wslides_quiz_radio_box">
- <input t-att-answer_id="answer.id" type="radio" t-att-name="question.id" t-att-question_id="question.id" t-att-value="answer.id" t-att-checked="'checked' if slide.id in user_progress and user_progress[slide.id].completed and answer.is_correct else False" t-att-disabled="slide.id in user_progress and user_progress[slide.id].completed"/>
- <span t-if="not slide.id in user_progress or not (user_progress[slide.id].completed and answer.is_correct)"/>
- <i t-if="slide.id in user_progress and user_progress[slide.id].completed and answer.is_correct" class="fa fa-check-circle"></i>
- </label>
- <span t-esc="answer.text_value"/>
- </li>
- <t t-set="j" t-value="j+1"/>
- </t>
- </ul>
- </div>
- </div>
- <t t-set="i" t-value="i+1"/>
- </t>
- <button t-if="not slide.id in user_progress or not user_progress[slide.id].completed" class="btn btn-primary submit-quiz" >Check your answers</button>
- <a t-if="next_slide" t-attf-style="#{'display: none' if not slide.id in user_progress or not user_progress[slide.id].completed else ''}" class="btn btn-primary next-slide" t-attf-href="/slides/slide/#{next_slide}">Continue</a>
- </div>
-
- </div>
- </div>
- <div class="row mt-3">
- <div class="col-lg-6">
- <div clas="row">
- <div class="col-lg-8" t-if="slide.tag_ids">
- <t t-foreach="slide.tag_ids" t-as="slide_tag">
- <a t-attf-href="/slides/#{slug(slide.channel_id)}/tag/#{slug(slide_tag)}" t-attf-class="badge badge-secondary ml4 mb4 float-right #{search_tag and search_tag.id == slide_tag.id and 'badge-primary' ''}" t-field="slide_tag.name"/>
- </t>
- </div>
- </div>
- </div>
- </div>
- <div class="row">
- <div class="col-md-8 col-12">
- <div class="float-left">
- <a t-attf-href="/slides/#{slug(slide.channel_id)}" t-attf-title="Channel #{slide.channel_id.name}" t-attf-aria-label="Channel #{slide.channel_id.name}">
- <i class="fa fa-circle-o fa-5x" style="color: #875A7B;font-weight: bold;"></i>
- </a>
- </div>
- <div style="margin-left: 80px">
- <p>
- <a t-attf-href="/slides/#{slug(slide.channel_id)}" t-esc="slide.channel_id.name"/>
- <t t-if="slide and slide.category_id">
- <span> / </span>
- <a t-attf-href="/slides/#{slug(slide.channel_id)}/category/#{slug(slide.category_id)}" t-esc="slide.category_id.name"/>
- </t>
- </p>
- <div>
- <t t-call="website_mail.follow">
- <t t-set="email" t-value="user.email"/>
- <t t-set="object" t-value="slide.channel_id"/>
- </t>
- </div>
- </div>
- </div>
- <div class="col-md-4 d-none d-md-block">
- <div class="text-right">
- <b class="h3 text-muted"><t t-esc="slide.total_views"/></b> <span class="text-muted small">views</span>
- </div>
- <!-- progress bar of like/dislike -->
- <div class="progress mb0" style="height: 2px;" t-if="slide.likes + slide.dislikes > 0">
- <div class="progress-bar" role="progressbar" t-attf-aria-valuenow="#{slide.likes}" aria-valuemin="0"
- t-attf-aria-valuemax="#{slide.likes + slide.dislikes}"
- t-attf-style="width: #{(slide.likes*100)/(slide.likes + slide.dislikes)}%;">
- <span class="sr-only"><t t-esc="slide.likes"/> Likes</span>
- </div>
- </div>
- <div class="progress mb0" style="height: 2px;" t-if="slide.likes + slide.dislikes == 0">
- <div class="progress-bar" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="width: 0%;">
- <span class="sr-only"><t t-esc="slide.likes"/> Likes</span>
- </div>
- </div>
-
- <div class="mt8">
- <t t-call="website_rating.rating_stars_static_popup_composer">
- <t t-set="rating_avg" t-value="rating_avg"/>
- <t t-set="rating_total" t-value="rating_count"/>
- <t t-set="object" t-value="slide"/>
- <t t-set="hash" t-value="message_post_hash"/>
- <t t-set="pid" t-value="message_post_pid"/>
- <t t-set="default_message_id" t-value="last_message_id"/>
- <t t-set="default_message" t-value="last_message"/>
- <t t-set="default_rating_value" t-value="last_rating_value"/>
- <t t-set="force_submit_url" t-value="'/slides/mail/update_comment' if last_message_id else False"/>
- </t>
- </div>
-
- <!-- LikeButton widget -->
- <div t-if="slide.channel_id.channel_type == 'documentation' and slide.channel_id.allow_comment" class="text-muted mt4">
- <div class="float-right mb16 text-right o_wslides_js_slide_like">
- <span
- class="o_wslides_js_slide_like_up"
- t-att-data-slide-id="slide.id"
- tabindex="0"
- data-toggle="popover">
- <i class="fa fa-thumbs-up fa-1x" role="img" aria-label="Likes" title="Likes"/>
- <span t-esc="slide.likes"/>
- </span>
- <span
- class="o_wslides_js_slide_like_down"
- t-att-data-slide-id="slide.id"
- tabindex="0"
- data-toggle="popover">
- <i class="fa fa-thumbs-down fa-1x" role="img" aria-label="Dislikes" title="Dislikes"/>
- <span t-esc="slide.dislikes"/>
- </span>
- </div>
- </div>
- </div>
- </div>
- <div class="mt8 mb32">
- <ul class="nav nav-tabs" role="tablist">
- <li class="nav-item">
- <a href="#about" aria-controls="about" t-attf-class="nav-link#{not comments and ' active' or ''}" role="tab" data-toggle="tab">
- <i class="fa fa-home"></i> About
- </a>
- </li>
- <li class="nav-item">
- <a href="#share" aria-controls="share" class="nav-link" role="tab" data-toggle="tab">
- <i class="fa fa-share-alt"></i> Share
- </a>
- </li>
- <li t-if="slide.channel_id.allow_comment" class="nav-item">
- <a href="#discuss" aria-controls="discuss" t-attf-class="nav-link#{comments and ' active' or ''}" role="tab" data-toggle="tab">
- <i class="fa fa-comments-o"></i> Comments
- </a>
- </li>
- <li t-if="not slide.channel_id.allow_comment" class="nav-item">
- <a href="#discuss" aria-controls="discuss" t-attf-class="nav-link#{comments and ' active' or ''}" role="tab" data-toggle="tab">
- <i class="fa fa-star"></i> Ratings
- </a>
- </li>
- <li class="nav-item">
- <a href="#transcript" aria-controls="transcript" class="nav-link" role="tab" data-toggle="tab">
- <i class="fa fa-align-justify"></i> Transcript
- </a>
- </li>
- <li class="nav-item">
- <a href="#statistic" aria-controls="statistic" class="nav-link" role="tab" data-toggle="tab">
- <i class="fa fa-bar-chart"></i> Statistics
- </a>
- </li>
- <li class="nav-item" t-if="slide.link_ids">
- <a href="#external-links" aria-controls="external-links" class="nav-link" role="tab" data-toggle="tab">
- <i class="fa fa-link"></i> External Links
- </a>
- </li>
- </ul>
- <div class="tab-content" style="padding: 20px 5px 5px 5px; word-wrap: break-word;">
- <div role="tabpanel" t-att-class="not comments and 'tab-pane fade in show active' or 'tab-pane fade'" id="about">
- <div t-field="slide.description"/>
- </div>
- <div role="tabpanel" class="tab-pane fade" t-if="slide.website_published" id="share">
- <t t-call="website_slides.slide_social_media">
- <t t-set="slide" t-value="slide"/>
- </t>
- <t t-call="website_slides.slide_social_email">
- <t t-set="slide" t-value="slide"/>
- </t>
- <t t-if="not slide.document_id">
- <t t-call="website_slides.slide_social_embed">
- <t t-set="slide" t-value="slide"/>
- </t>
- </t>
- </div>
- <div role="tabpanel" class="tab-pane fade" t-if="not slide.website_published" id="share">
- <h4><i class="fa fa-info-circle"></i>
- The social sharing module will be unlocked when a moderator will allow your publication.
- </h4>
- </div>
- <div role="tabpanel" t-att-class="comments and 'tab-pane fade in show active' or 'tab-pane fade'" id="discuss">
- <t t-call="portal.message_thread">
- <t t-set="object" t-value="slide"/>
- <t t-set="hash" t-value="message_post_hash"/>
- <t t-set="pid" t-value="message_post_pid"/>
- <t t-set="display_rating" t-value="slide.channel_id.channel_type == 'training'"/>
- </t>
- </div>
- <div role="tabpanel" class="tab-pane fade oe_slides_transcript" id="transcript">
- <t t-if="slide.index_content">
- <t t-foreach="slide.index_content.split('\n')" t-as="line">
- <p t-esc="line"></p>
- </t>
- </t>
- </div>
- <div role="tabpanel" class="tab-pane fade" id="statistic" t-att-slide-url="slide.website_url">
- <div class="row">
- <div class="col-lg-4">
- <h4 class="mt0 oe_slides_statistics_title">Views</h4>
- <ul class="list-group">
- <li class="list-group-item">
- <span class="badge badge-pill"><t t-esc="slide.total_views"/></span>
- <i class="fa fa-play"></i> Total Views
- </li>
- <li class="list-group-item">
- <span class="badge badge-pill"><t t-esc="slide.slide_views"/></span>
- <i class="fa fa-circle-o"></i> Website Views
- </li>
- <li class="list-group-item" t-if="not slide.document_id">
- <span class="badge badge-pill"><t t-esc="slide.public_views"/></span>
- <i class="fa fa-code"></i> Public Views
- </li>
- </ul>
- </div>
-
- <div class="col-lg-4">
- <h4 class="mt0 oe_slides_statistics_title">Actions</h4>
- <ul class="list-group">
- <li class="list-group-item">
- <span class="badge badge-pill" id="total-share">0</span>
- <i class="fa fa-share-alt"></i> Social Shares
- </li>
- <li class="list-group-item">
- <span class="badge badge-pill"><t t-esc="slide.likes"/></span>
- <i class="fa fa-thumbs-up"></i> Likes
- </li>
- <li class="list-group-item">
- <span class="badge badge-pill"><t t-esc="slide.dislikes"/></span>
- <i class="fa fa-thumbs-down"></i> Dislikes
- </li>
- <li class="list-group-item">
- <span class="badge badge-pill"><t t-esc="len(slide.website_message_ids)"/></span>
- <i class="fa fa-comments-o"></i> Comments
- </li>
- </ul>
- </div>
+ </div>
+ </div>
+</template>
- <div class="col-lg-4">
- <h4 class="mt0 oe_slides_statistics_title">Share count</h4>
- <ul class="list-group">
- <li class="list-group-item">
- <span class="badge badge-pill" id="facebook-badge">0</span>
- <i class="fa fa-facebook-square"></i> Facebook
- </li>
- <li class="list-group-item">
- <span class="badge badge-pill" id="twitter-badge">0</span>
- <i class="fa fa-twitter-square"></i> Twitter
- </li>
- <li class="list-group-item">
- <span class="badge badge-pill" id="linkedin-badge">0</span>
- <i class="fa fa-linkedin-square"></i> LinkedIn
- </li>
- <li class="list-group-item">
- <span class="badge badge-pill" id="google-badge">0</span>
- <i class="fa fa-google-plus-square"></i> Google+
- </li>
- </ul>
- </div>
- </div>
- <div class="row" t-if="not slide.document_id and slide.embedcount_ids">
- <div class="col-lg-12">
- <h4 class="mt0"> Embeds
- <span class="badge badge-pill float-right">
- <t t-esc="len(slide.embedcount_ids)"/>
- </span>
- </h4>
- <ul class="list-group" style="height: 150px;overflow: auto;">
- <li class="list-group-item" t-foreach="slide.embedcount_ids" t-as="count">
- <span class="badge badge-pill"><t t-esc="count.count_views"/></span>
- <t t-esc="count.url"/>
- </li>
- </ul>
- </div>
- </div>
- </div>
- <div role="tabpanel" class="tab-pane fade oe_slides_transcript" id="external-links" t-if="slide.link_ids">
- <ul>
- <t t-foreach="slide.link_ids" t-as="link">
- <li><a t-att-href="link.link" target="_blank"><t t-esc="link.name"/></a></li>
- </t>
- </ul>
- </div>
- </div>
- </div>
- </div>
- </div>
- <div t-if="slide.channel_id.channel_type == 'documentation'" class="col-xl-4 col-lg-4 col-md-12 col-12">
- <ul class="nav nav-tabs" role="tablist">
- <li class="nav-item"><a aria-controls="related" href="#related" class="nav-link active" data-toggle="tab">Related</a></li>
- <li class="nav-item"><a aria-controls="most_viewed" href="#most_viewed" class="nav-link" data-toggle="tab">Most Viewed</a></li>
- </ul>
- <div class="tab-content">
- <div role="tabpanel" id="related" class="tab-pane active mt4">
- <div class="card">
- <ul class="list-unstyled card-body">
- <t t-set="related_slides_list" t-value="list(related_slides)"/>
- <t t-if="not related_slides_list">
- No presentation available.
- </t>
- <t t-else="" t-foreach="related_slides_list" t-as="related_slide">
- <t t-call="website_slides.related_slides"/>
- </t>
- </ul>
- </div>
- </div>
- <div role="tabpanel" id="most_viewed" class="tab-pane mt4">
- <div class="card">
- <ul class="list-unstyled card-body">
- <t t-set="most_viewed_slides_list" t-value="list(most_viewed_slides)"/>
- <t t-if="not list(most_viewed_slides_list)">
- No presentation available.
- </t>
- <t t-else="" t-foreach="most_viewed_slides_list" t-as="related_slide">
- <t t-call="website_slides.related_slides"/>
- </t>
- </ul>
- </div>
- </div>
- </div>
- </div>
- </div>
+<!-- Slide sub-template: display an item in a list of related slides (Related, Most Viewed, ...) -->
+<template id="slide_aside_card" name="Related Slide">
+ <li class="media mt-2 row">
+ <t t-set="slide_image" t-value="'/web/image/slide.slide/%s/image_medium' % aside_slide.id"/>
+ <a t-att-href="'/slides/slide/%s' % (slug(aside_slide))" t-att-title="aside_slide.name" class="col-4">
+ <div class="img" t-attf-style="padding-top: 50%; background-image: url(#{slide_image}); background-size: cover; background-position:center"/>
+ </a>
+ <div class="media-body col-8 pl-0">
+ <a t-att-href="'/slides/slide/%s' % (slug(aside_slide))">
+ <h6 t-esc="aside_slide.name" class="mb-1"/>
+ </a>
+ <small class="text-muted">
+ <t t-esc="aside_slide.total_views"/> Views &#8226; <timeago class="timeago" t-att-datetime="aside_slide.create_date"></timeago>
+ </small>
</div>
- </t>
+ </li>
</template>
-<template id="slide_detail_list_category_view" name="Category item for the slide detailed view list">
- <a data-toggle="collapse" t-att-href="('#collapse-%s') % (category.id if category else 0)" role="button" aria-expanded="true" t-att-aria-controls="('collapse-%s') % (category.id if category else 0)">
- <li class="o_wslides_slide_content_list_section">
+<!-- Slide: sidebar training mode -->
+<template id="slide_aside_training" name="Slide: Sidebar in Training">
+ <div class="o_wslides_lesson_aside_list bg-white">
+ <h5 class="bg-100 text-600 pl-2 py-2 border-bottom">Course content</h5>
+ <ul class="p-0 list-unstyled">
+ <t t-set="i" t-value="0"/>
+ <t t-if="uncategorized_slides" t-call="website_slides.slide_aside_training_category">
+ <t t-set="category_slide_ids" t-value="uncategorized_slides"/>
+ </t>
+ <t t-foreach="slide.channel_id.category_ids" t-as="category">
+ <t t-call="website_slides.slide_aside_training_category">
+ <t t-set="category_slide_ids" t-value="category.slide_ids"/>
+ </t>
+ </t>
+ </ul>
+ </div>
+</template>
+
+<template id="slide_aside_training_category" name="Category item for the slide detailed view list">
+ <li class="mt-3">
+ <a t-att-href="('#collapse-%s') % (category.id if category else 0)" data-toggle="collapse" role="button" aria-expanded="true"
+ class="o_wslides_lesson_aside_list_link pl-2 text-600"
+ t-att-aria-controls="('collapse-%s') % (category.id if category else 0)">
<t t-if="category">
- <span t-field="category.name"/>
+ <span t-field="category.name" class="text-uppercase"/>
</t>
<t t-else="">
- <span>Uncategorized</span>
+ <span class="text-uppercase">Uncategorized</span>
</t>
- </li>
- </a>
- <ul class="collapse show p-0 m-0" t-att-id="('collapse-%s') % (category.id if category else 0)" >
- <t t-foreach="category_slide_ids" t-as="course_slide">
- <li class="o_wslides_slide_content_list_slide">
- <a t-attf-href="/slides/slide/#{slug(course_slide)}" t-att-index="i" t-att-slide_id="course_slide.id">
- <div t-attf-index="#{i}" t-attf-slide_id="#{course_slide.id}" t-attf-class="#{'active' if slide.id == course_slide.id else ''} d-flex justify-content-between">
- <div>
- <t t-call="website_slides.slide_icon">
- <t t-set="slide" t-value="course_slide" />
- </t>
- <t t-esc="course_slide.name"/>
- </div>
- <div class="o_wslides_slides_list_slide_controls mr-2">
- <i t-attf-id="check-#{course_slide.id}" t-if="course_slide.id in user_progress and not user_progress[course_slide.id].completed" class="check-done fa fa-check-circle text-muted mr-1"></i>
- <i t-attf-id="check-#{course_slide.id}" t-if="course_slide.id in user_progress and user_progress[course_slide.id].completed" class="check-done text-primary fa fa-check-circle mr-1"></i>
- </div>
- </div>
- </a>
- <ul t-if="course_slide.link_ids" class="list-group">
- <t t-foreach="course_slide.link_ids" t-as="resource">
- <a t-attf-href="#{resource.link}" target="new">
- <li><div><i class="fa fa-link"></i><span t-field="resource.link"/></div></li>
+ </a>
+ <ul class="collapse show p-0 m-0 list-unstyled" t-att-id="('collapse-%s') % (category.id if category else 0)" >
+ <t t-foreach="category_slide_ids" t-as="aside_slide">
+ <li class="p-0">
+ <a t-att-href="'/slides/slide/%s' % (slug(aside_slide))"
+ t-att-class="'o_wslides_lesson_aside_list_link d-flex align-items-center pl-2 %s' % ('bg-200 border-left border-primary' if aside_slide == slide else '')">
+ <i t-att-id="'o_wslides_lesson_aside_slide_check_%s' % (aside_slide.id)"
+ t-att-class="'mr-2 fa %s' % ('text-success fa-check-circle' if channel_progress[aside_slide.id].get('completed') else 'text-600 fa-circle-o')">
+ </i>
+ <t t-call="website_slides.slide_icon">
+ <t t-set="icon_class" t-value="'mr-2 %s' % ('text-800' if aside_slide == slide else 'text-600')"/>
+ <t t-set="slide" t-value="aside_slide"/>
+ </t>
+ <span t-esc="aside_slide.name" t-att-class="'mr-2 %s' % ('text-800' if aside_slide == slide else 'text-600')"/>
+ <span t-if="aside_slide.question_ids"
+ t-att-class="'ml-auto badge badge-pill %s' % ('badge-success' if channel_progress[aside_slide.id].get('completed') else 'badge-light text-600')">
+ <t t-esc="channel_progress[aside_slide.id].get('quiz_karma_won') if channel_progress[aside_slide.id].get('completed') else channel_progress[aside_slide.id].get('quiz_karma_gain')"/> xp
+ </span>
+ </a>
+ <ul t-if="aside_slide.link_ids or aside_slide.question_ids" class="list-group ml-5 list-unstyled">
+ <t t-foreach="aside_slide.link_ids" t-as="resource">
+ <a t-attf-href="#{resource.link}" target="new">
+ <li><small><i class="fa fa-link mr-1"></i><span t-field="resource.name"/></small></li>
+ </a>
+ </t>
+ <a t-if="aside_slide.question_ids" t-att-href="'/slides/slide/%s#lessonQuiz' % (slug(aside_slide))"
+ class="o_wslides_lesson_aside_list_link text-600">
+ <li><small><i class="fa fa-flag-o text-warning"></i> Quiz</small></li>
</a>
- </t>
- </ul>
+ </ul>
+ </li>
+ </t>
+ </ul>
+ </li>
+</template>
+
+<!-- Slide: all its content, not fullscreen mode -->
+<template id="slide_content_detailed" name="Slide: Detailed Content">
+ <div class="row">
+ <div class="col-12 d-flex mt-3">
+ <div class="d-flex align-items-center">
+ <h5 class="m-0 ml-2">
+ <t t-call="website_slides.slide_icon"/>
+ <span t-field="slide.name"/>
+ </h5>
+ <span t-if="slide.question_ids"
+ t-att-class="'ml-2 badge %s' % ('badge-success' if channel_progress[slide.id].get('completed') else 'badge-info')">
+ <span t-if="channel_progress[slide.id].get('completed')">
+ <i class="fa fa-check-circle"/>
+ <t t-esc="channel_progress[slide.id].get('quiz_karma_won', 0)"/>
+ </span>
+ <span t-else="" t-esc="channel_progress[slide.id].get('quiz_karma_gain', 0)"/>
+ <span>XP</span>
+ </span>
+ </div>
+ <div class="ml-auto">
+ <a t-att-class="'btn btn-light border %s' % ('disabled' if not previous_slide else '')"
+ role="button" t-att-aria-disabled="'disabled' if not previous_slide else None"
+ t-att-href="'/slides/slide/%s' % (slug(previous_slide)) if previous_slide else '#'">
+ <i class="fa fa-chevron-left mr-2"></i> Prev
+ </a>
+ <a t-att-class="'btn btn-primary border text-white %s' % ('disabled' if slide.question_ids or channel_progress[slide.id].get('completed') else '')"
+ role="button" t-att-aria-disabled="'true' if slide.question_ids or channel_progress[slide.id].get('completed') else None"
+ t-att-href="'#' if slide.question_ids or channel_progress[slide.id].get('completed') else '/slides/slide/%s/set_completed?%s' % (slide.id, 'next_slide_id=%s' % (next_slide.id) if next_slide else '')">
+ Set Done
+ </a>
+ <a t-att-class="'btn btn-light border %s' % ('disabled' if not next_slide else '')"
+ role="button" t-att-aria-disabled="'disabled' if not next_slide else None"
+ t-att-href="'/slides/slide/%s' % (slug(next_slide)) if next_slide else '#'">
+ Next <i class="fa fa-chevron-right ml-2"></i>
+ </a>
+ <a t-att-class="'btn btn-light border ml-2 %s' % ('disabled' if slide.channel_id.channel_type != 'training' else '')"
+ role="button" t-att-aria-disabled="'disabled' if slide.channel_id.channel_type != 'training' else None"
+ t-att-href="'/slides/slide/%s?fullscreen=1' % (slug(slide)) if slide.channel_id.channel_type == 'training' else '#'"><i class="fa fa-desktop mr-2"></i> Fullscreen</a>
+ </div>
+ </div>
+ <div t-if="slide.tag_ids" class="col">
+ <t t-foreach="slide.tag_ids" t-as="tag">
+ <a t-att-href="'/slides/%s/tag/%s' % (slug(slide.channel_id), slug(tag))" class="badge badge-light" t-esc="tag.name"/>
+ </t>
+ </div>
+ </div>
+ <div class="row o_wslides_lesson_content_type">
+ <img t-if="slide.slide_type == 'infographic'"
+ t-attf-src="/web/image/slide.slide/#{slide.id}/image" class="img-fluid" style="width:100%" t-att-alt="slide.name"/>
+ <div t-if="slide.slide_type in ('presentation', 'document')" class="embed-responsive embed-responsive-4by3 embed-responsive-item mb8" style="height: 600px;">
+ <t t-raw="slide.embed_code"/>
+ </div>
+ <div t-if="slide.slide_type == 'video' and slide.document_id" class="embed-responsive embed-responsive-16by9 embed-responsive-item mb8">
+ <t t-raw="slide.embed_code"/>
+ </div>
+ <div t-if="slide.slide_type == 'webpage'" class="bg-white">
+ <div t-field="slide.html_content"/>
+ </div>
+ </div>
+ <div class="row" t-if="slide.question_ids">
+ <t t-call="website_slides.lesson_content_quiz"/>
+ </div>
+ <div class="row mt-3 mb-3">
+ <div class="col d-flex align-items-start">
+ <span t-if="slide.link_ids" class="text-muted font-weight-bold mr-3">External sources</span>
+ <div class="text-muted mr-auto border-left pl-3">
+ <t t-foreach="slide.link_ids" t-as="link">
+ <a t-att-href="link.link" t-esc="link.name"/><br />
+ </t>
+ </div>
+
+ <span class="text-muted font-weight-bold mr-3">Rating</span>
+ <div class="text-muted border-left pl-3">
+ <div class="o_wslides_js_slide_like mr-2">
+ <span class="o_wslides_js_slide_like_up" tabindex="0" data-toggle="popover" t-att-data-slide-id="slide.id">
+ <i class="fa fa-thumbs-up fa-1x" role="img" aria-label="Likes" title="Likes"></i>
+ <span t-esc="slide.likes"/>
+ </span>
+ <span class="o_wslides_js_slide_like_down ml-3" tabindex="0" data-toggle="popover" t-att-data-slide-id="slide.id">
+ <i class="fa fa-thumbs-down fa-1x" role="img" aria-label="Dislikes" title="Dislikes"></i>
+ <span t-esc="slide.dislikes"/>
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="row mb-5">
+ <ul class="col-lg-12 nav nav-tabs o_wslides_lesson_nav" role="tablist">
+ <li class="nav-item">
+ <a href="#about" aria-controls="about" class="nav-link active" role="tab" data-toggle="tab">
+ <i class="fa fa-home"></i> About
+ </a>
</li>
- <t t-set="i" t-value="i+1"/>
- </t>
- </ul>
+ <li t-if="slide.channel_id.allow_comment" class="nav-item">
+ <a href="#discuss" aria-controls="discuss" class="nav-link" role="tab" data-toggle="tab">
+ <i class="fa fa-comments-o"></i> Comments
+ </a>
+ </li>
+ <li class="nav-item">
+ <a href="#transcript" aria-controls="transcript" class="nav-link" role="tab" data-toggle="tab">
+ <i class="fa fa-align-justify"></i> Transcript
+ </a>
+ </li>
+ <li class="nav-item">
+ <a href="#statistic" aria-controls="statistic" class="nav-link" role="tab" data-toggle="tab">
+ <i class="fa fa-bar-chart"></i> Statistics
+ </a>
+ </li>
+ <li class="nav-item">
+ <a href="#share" aria-controls="share" class="nav-link" role="tab" data-toggle="tab">
+ <i class="fa fa-share-alt"></i> Share
+ </a>
+ </li>
+ </ul>
+ <div class="col tab-content mt-3">
+ <div role="tabpanel" t-att-class="not comments and 'tab-pane fade in show active' or 'tab-pane fade'" id="about">
+ <div t-field="slide.description"/>
+ </div>
+ <div role="tabpanel" t-att-class="comments and 'tab-pane fade in show active' or 'tab-pane fade'" id="discuss">
+ <t t-call="portal.message_thread">
+ <t t-set="object" t-value="slide"/>
+ <t t-set="hash" t-value="message_post_hash"/>
+ <t t-set="pid" t-value="message_post_pid"/>
+ <t t-set="display_rating" t-value="False"/>
+ </t>
+ </div>
+ <div role="tabpanel" class="tab-pane fade oe_slides_transcript" id="transcript">
+ <t t-if="slide.index_content">
+ <t t-foreach="slide.index_content.split('\n')" t-as="line">
+ <p t-esc="line"></p>
+ </t>
+ </t>
+ </div>
+ <div role="tabpanel" class="tab-pane fade" id="statistic" t-att-slide-url="slide.website_url">
+ <div class="row">
+ <div class="col-lg-4 d-flex flex-column">
+ <span class="font-weight-bold mb-3 mt-3">Views</span>
+ <span class="d-flex pl-3">
+ <span class="badge badge-pill" t-esc="slide.total_views"/>
+ <span class="mr-5 ml-auto">Total Views</span>
+ </span>
+ <span class="d-flex pl-3">
+ <span class="badge badge-pill" t-esc="slide.slide_views"/>
+ <span class="mr-5 ml-auto">Members Views</span>
+ </span>
+ <span class="d-flex pl-3">
+ <span class="badge badge-pill" t-esc="slide.public_views"/>
+ <span class="mr-5 ml-auto">Public Views</span>
+ </span>
+ </div>
+ <div class="col-lg-4 d-flex flex-column">
+ <span class="font-weight-bold mb-3 mt-3">Actions</span>
+ <span class="d-flex pl-3">
+ <span class="badge badge-pill" t-esc="slide.likes"/>
+ <span class="mr-5 ml-auto">Likes</span>
+ </span>
+ <span class="d-flex pl-3">
+ <span class="badge badge-pill" t-esc="slide.dislikes"/>
+ <span class="mr-5 ml-auto">Dislikes</span>
+ </span>
+ <span class="d-flex pl-3">
+ <span class="badge badge-pill" t-esc="len(slide.website_message_ids)"/>
+ <span class="mr-5 ml-auto">Comments</span>
+ </span>
+ </div>
+ <div class="col-lg-4 d-flex flex-column">
+ <span class="font-weight-bold mb-3 mt-3">Share Count</span>
+ <span class="d-flex pl-3">
+ <span class="badge badge-pill" id="facebook-badge">0</span>
+ <span class="mr-5 ml-auto">Facebook</span>
+ </span>
+ <span class="d-flex pl-3">
+ <span class="badge badge-pill" id="twitter-badge">0</span>
+ <span class="mr-5 ml-auto">Twitter</span>
+ </span>
+ <span class="d-flex pl-3">
+ <span class="badge badge-pill" id="linkedin-badge">0</span>
+ <span class="mr-5 ml-auto">LinkedIn</span>
+ </span>
+ </div>
+ </div>
+ </div>
+ <div role="tabpanel" class="tab-pane fade" t-if="slide.website_published" id="share">
+ <h4 t-if="not slide.website_published"><i class="fa fa-info-circle"></i>
+ The social sharing module will be unlocked when a moderator will allow your publication.
+ </h4>
+ <t t-if="slide.website_published">
+ <t t-call="website_slides.slide_social_media">
+ <t t-set="slide" t-value="slide"/>
+ </t>
+ <t t-call="website_slides.slide_social_email">
+ <t t-set="slide" t-value="slide"/>
+ </t>
+ <t t-if="not slide.document_id">
+ <t t-call="website_slides.slide_social_embed">
+ <t t-set="slide" t-value="slide"/>
+ </t>
+ </t>
+ </t>
+ </div>
+ </div>
+ </div>
</template>
-<!-- Slide sub-template: display an item in a list of related slides (Related, Most Viewed, ...) -->
-<template id="related_slides" name="Related Slide">
- <li class="media mt-3">
- <a t-attf-href="/slides/slide/#{slug(related_slide)}" class="mr-3">
- <img class="oe_slides_apart_small" t-attf-src="/web/image/slide.slide/#{related_slide.id}/image_small" alt="related_slide.name"/>
- </a>
- <div class="media-body">
- <a t-attf-href="/slides/slide/#{slug(related_slide)}"><h6 t-esc="related_slide.name" class="mb-1"/></a>
- <small class="text-muted">
- <t t-esc="related_slide.total_views"/> Views . <timeago class="timeago" t-att-datetime="related_slide.create_date"></timeago>
- </small>
+<!-- Slide sub-tempalte: render a quiz serverside. Should be sync with JS qweb template "slide.slide.quiz" -->
+<template id="lesson_content_quiz" name="Lesson: Quiz specific content">
+ <div class="o_wslides_js_lesson_quiz col" id="lessonQuiz"
+ t-att-data-id="slide.id"
+ t-att-data-name="slide.name"
+ t-att-data-slide-type="slide.slide_type"
+ t-att-data-readonly="not channel_progress[slide.id]"
+ t-att-data-completed="channel_progress[slide.id].get('completed')"
+ t-att-data-quiz-attempts-count="quiz_attempts_count"
+ t-att-data-quiz-karma-max="quiz_karma_max"
+ t-att-data-quiz-karma-gain="quiz_karma_gain"
+ t-att-data-quiz-karma-won="quiz_karma_won"
+ t-att-data-has-next="1 if next_slide else 0"
+ t-att-data-next-slide-url="'/slides/slide/%s' % (slug(next_slide)) if next_slide else None">
+ <div t-foreach="slide.question_ids" t-as="question"
+ class="o_wslides_js_lesson_quiz_question mt-3" t-att-data-question-id="question.id" t-att-data-title="question.question">
+ <div class="h3">
+ <small class="text-muted"><span t-esc="question_index+1"/>. </small> <span t-esc="question.question"/>
+ </div>
+ <ul class="bg-white list-unstyled">
+ <t t-foreach="question.answer_ids" t-as="answer">
+ <li t-att-data-answer-id="answer.id"
+ t-att-data-text="answer.text_value"
+ class="o_wslides_quiz_answer pt-2 pb-2 border-bottom rounded d-flex align-items-center">
+ <label class="mb-0 d-flex align-items-center justify-content-center mr-3 ml-3">
+ <input type="radio"
+ t-att-name="question.id"
+ t-att-value="answer.id"
+ class="d-none"/>
+ <i class="fa fa-circle text-muted"></i>
+ <i class="fa fa-times-circle text-danger d-none"></i>
+ <i class="fa fa-check-circle text-success d-none"></i>
+ </label>
+ <span t-esc="answer.text_value"/>
+ </li>
+ </t>
+ </ul>
</div>
- </li>
+ <div class="alert alert-danger o_wslides_js_lesson_quiz_error d-none" role="alert">
+ <span></span>
+ <button type="button" class="close" data-dismiss="alert" aria-label="Close">
+ <span aria-hidden="true">&amp;times;</span>
+ </button>
+ </div>
+ <div class="o_wslides_js_lesson_quiz_validation"/>
+ </div>
</template>
<!-- Slide sub-template: share: send by email -->
diff --git a/addons/website_slides/views/website_slides_templates_lesson_fullscreen.xml b/addons/website_slides/views/website_slides_templates_lesson_fullscreen.xml
index 8b7a649d107..1361559c23f 100644
--- a/addons/website_slides/views/website_slides_templates_lesson_fullscreen.xml
+++ b/addons/website_slides/views/website_slides_templates_lesson_fullscreen.xml
@@ -73,17 +73,18 @@
<li t-att-index="i" t-att-data-slide-id="course_slide.id" t-attf-class="#{'active' if slide.id == course_slide.id else ''}">
<span>
<span class="o_wslides_top_line"></span>
- <i t-attf-id="check-#{course_slide.id}" t-if="not course_slide.id in user_progress or not user_progress[course_slide.id].completed" class="check-done fa fa-circle-thin"></i>
- <i t-attf-id="check-#{course_slide.id}" t-if="course_slide.id in user_progress and user_progress[course_slide.id].completed" class="check-done o_wslides_slide_completed fa fa-check-circle"></i>
+ <i t-attf-id="check-#{course_slide.id}" t-if="not channel_progress[course_slide.id].get('completed')" class="check-done fa fa-circle-thin"></i>
+ <i t-attf-id="check-#{course_slide.id}" t-if="channel_progress[course_slide.id].get('completed')" class="check-done o_wslides_slide_completed fa fa-check-circle"></i>
<span class="o_wslides_bottom_line"></span>
</span>
<a t-att-index="i" t-att-data-slide-id="course_slide.id">
<div t-attf-index="#{i}"
t-att-data-slug="slug(course_slide)"
- t-att-data-done="course_slide.id in user_progress and user_progress[course_slide.id].completed"
+ t-att-data-done="channel_progress[course_slide.id].get('completed')"
t-att-data-id="course_slide.id"
+ t-att-data-readonly="not course_slide.channel_id.is_member"
t-att-data-name="course_slide.name"
- t-attf-data-has_quiz="#{True if course_slide.question_ids else False}"
+ t-att-data-has-quiz="True if course_slide.question_ids else False"
t-att-data-slide_type="course_slide.slide_type"
t-att-data-embed_code="course_slide.embed_code"
t-attf-class="o_wslides_fs_sidebar_slide_tab #{'active' if slide.id == course_slide.id else ''} d-flex justify-content-between">
diff --git a/addons/website_slides/views/website_slides_templates_profile.xml b/addons/website_slides/views/website_slides_templates_profile.xml
index f25c53131ff..5c862747056 100644
--- a/addons/website_slides/views/website_slides_templates_profile.xml
+++ b/addons/website_slides/views/website_slides_templates_profile.xml
@@ -13,26 +13,18 @@
<xpath expr="//div[@id='profile_about_badge']" position="before">
<t t-if="channel">
<div class="mb32">
- <h4><i class="fa fa-graduation-cap"/> Completed Courses</h4>
- <t t-if="courses_completed">
- <t t-call="website_slides.display_course">
- <t t-set="courses" t-value="courses_completed"></t>
- </t>
- </t>
- <t t-else="">
- <span class="font-weight-bold">No completed course yet!</span>
+ <h5 class="border-bottom pb-1">Completed Courses</h5>
+ <t t-if="courses_completed" t-call="website_slides.display_course">
+ <t t-set="courses" t-value="courses_completed"></t>
</t>
+ <p t-else="" class="text-muted">No completed course yet</p>
</div>
<div class="mb32">
- <h4><i class="fa fa-book"/> Followed Courses</h4>
- <t t-if="courses_ongoing">
- <t t-call="website_slides.display_course">
- <t t-set="courses" t-value="courses_ongoing"></t>
- </t>
- </t>
- <t t-else="">
- <span class="font-weight-bold">No followed course!</span>
+ <h5 class="border-bottom pb-1">Followed Courses</h5>
+ <t t-if="courses_ongoing" t-call="website_slides.display_course">
+ <t t-set="courses" t-value="courses_ongoing"></t>
</t>
+ <p t-else="" class="text-muted">No followed course yet</p>
</div>
</t>
</xpath>
@@ -40,27 +32,34 @@
<template id="display_course">
<div class="row">
- <div class="col-lg-6 o_wprofile_slides_card" t-foreach="courses" t-as="course">
- <div class="card-body o_wprofile_slides_course_card_body o_wprofile_bb_grey" t-attf-onclick="location.href='/slides/#{slug(course.channel_id)}';">
- <div class="row">
- <div class="o_wprofile_slides_course_img">
- <img class="o_wprofile_img_small" t-attf-src="/web/image/slide.channel/#{course.channel_id.id}/image_small"/>
- </div>
- <div class="col-10 o_wprofile_slides_title">
- <span class="font-weight-bold" t-field="course.channel_id.name"/>
- </div>
- <div class="o_wprofile_slide_course_rating">
- <t t-call="website_rating.rating_widget_stars_static">
- <t t-set="rating_avg" t-value="course.channel_id.rating_avg"/>
- <t t-set="rating_count" t-value="course.channel_id.rating_count"/>
- </t>
- </div>
- <div class="row o_wprofile_slide_course_progress">
- <div class="col progress o_wprofile_slide_course_progress_bar">
- <div class="progress-bar" style="background-color: #00A09D !important"
- t-att-style="'width: '+ str(course.completion)+'%'"></div>
+ <div class="col-12 col-lg-6" t-foreach="courses" t-as="course">
+ <div class="card mb-2">
+ <div class="card-body o_wprofile_slides_course_card_body p-0 d-flex"
+ t-attf-onclick="location.href='/slides/#{slug(course.channel_id)}';">
+
+ <div class="pl-5 pr-4 rounded-left" t-attf-style="background-image:url(/web/image/slide.channel/#{course.channel_id.id}/image); background-size: cover; background-position: center"/>
+
+ <div class="p-2 w-100">
+ <h5 class="mt-0 mb-1" t-field="course.channel_id.name"/>
+
+ <div class="overflow-hidden mb-1" style="height:24px">
+ <t t-foreach="course.channel_id.tag_ids" t-as="tag">
+ <a t-att-href="'/slides/all?channel_tag_id=%s' % tag.id" class="badge badge-light" t-esc="tag.name"/>
+ </t>
+ <t t-foreach="course.channel_id.tag_ids" t-as="tag">
+ <a t-att-href="'/slides/all?channel_tag_id=%s' % tag.id" class="badge badge-light" t-esc="tag.name"/>
+ </t>
+ <t t-foreach="course.channel_id.tag_ids" t-as="tag">
+ <a t-att-href="'/slides/all?channel_tag_id=%s' % tag.id" class="badge badge-light" t-esc="tag.name"/>
+ </t>
+ </div>
+
+ <div class="d-flex align-items-center">
+ <div class="progress flex-grow-1" style="height:0.5em">
+ <div class="progress-bar bg-primary" t-att-style="'width: '+ str(course.completion)+'%'"/>
+ </div>
+ <small class="font-weight-bold pl-2"><span t-field="course.completion"/> %</small>
</div>
- <span class="col-3 text-primary o_wprofile_slide_course_progress_value"><span t-field="course.completion"/> %</span>
</div>
</div>
</div>
diff --git a/addons/website_slides_forum/__manifest__.py b/addons/website_slides_forum/__manifest__.py
index c367b8306c6..35c048e0108 100644
--- a/addons/website_slides_forum/__manifest__.py
+++ b/addons/website_slides_forum/__manifest__.py
@@ -14,9 +14,11 @@
'data': [
'views/slide_channel_views.xml',
'views/website_slides_templates.xml',
- 'data/slide_channel_demo.xml',
'views/assets.xml',
'views/website_slides_forum_templates.xml'
],
+ 'demo': [
+ 'data/slide_channel_demo.xml',
+ ],
'auto_install': True,
}
diff --git a/addons/website_slides_forum/controllers/main.py b/addons/website_slides_forum/controllers/main.py
index 052df5f4de2..2331f11ca46 100644
--- a/addons/website_slides_forum/controllers/main.py
+++ b/addons/website_slides_forum/controllers/main.py
@@ -8,15 +8,12 @@ from odoo.addons.website_slides.controllers.main import WebsiteSlides
class WebsiteSlidesForum(WebsiteSlides):
def _slide_channel_prepare_values(self, **kwargs):
- communication_type = kwargs.get('communication_type')
channel = super(WebsiteSlidesForum, self)._slide_channel_prepare_values(**kwargs)
- if communication_type:
- if communication_type == 'forum':
- forum = request.env['forum.forum'].create({
- 'name': kwargs.get('name')
- })
- channel['forum_id'] = forum.id
- channel['allow_comment'] = communication_type == 'comment'
+ if bool(kwargs.get('link_forum')):
+ forum = request.env['forum.forum'].create({
+ 'name': kwargs.get('name')
+ })
+ channel['forum_id'] = forum.id
return channel
# Profile
diff --git a/addons/website_slides_forum/data/slide_channel_demo.xml b/addons/website_slides_forum/data/slide_channel_demo.xml
index 99a21cbd523..502045b71f4 100644
--- a/addons/website_slides_forum/data/slide_channel_demo.xml
+++ b/addons/website_slides_forum/data/slide_channel_demo.xml
@@ -1,12 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
-<odoo><data noupdate="0">
+<odoo><data noupdate="1">
- <record id="forum_forum_demo_channel_1" model="forum.forum">
+ <record id="forum_forum_demo_channel_0" model="forum.forum">
<field name="name">Basics of Gardening</field>
</record>
+ <record id="website_slides.channel_public" model="slide.channel">
+ <field name="forum_id" ref="website_slides_forum.forum_forum_demo_channel_0"/>
+ </record>
- <record id="website_slides.slide_channel_demo_1_gard1" model="slide.channel">
- <field name="forum_id" ref="website_slides_forum.forum_forum_demo_channel_1"/>
+ <record id="forum_forum_demo_channel_2" model="forum.forum">
+ <field name="name">Trees, Wood and Gardens</field>
</record>
+ <record id="website_slides.slide_channel_demo_2_gard2" model="slide.channel">
+ <field name="forum_id" ref="website_slides_forum.forum_forum_demo_channel_2"/>
+ </record>
</data></odoo>
diff --git a/addons/website_slides_forum/static/src/xml/website_slides_forum_channel.xml b/addons/website_slides_forum/static/src/xml/website_slides_forum_channel.xml
index 03389f2a12d..f00c7e4e0ba 100644
--- a/addons/website_slides_forum/static/src/xml/website_slides_forum_channel.xml
+++ b/addons/website_slides_forum/static/src/xml/website_slides_forum_channel.xml
@@ -1,32 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-extend="website.slide.channel.create">
- <t t-jquery=".o_wslide_channel_communication_type" t-operation="replace">
- <fieldset id="channel_create_forum" class="form-group">
- <div class="row">
- <legend class="col-form-label col-sm-2">Communication</legend>
- <div class="col-sm-10">
- <div class="form-check">
- <input class="form-check-input" type="radio" name="communication_type" id="communication_type1" value="nothing" checked="checked"/>
- <label class="form-check-label" for="communication_type1">
- No communication
- </label>
- </div>
- <div class="form-check">
- <input class="form-check-input" type="radio" name="communication_type" id="communication_type2" value="forum"/>
- <label class="form-check-label" for="communication_type2">
- Create a Forum
- </label>
- </div>
- <div class="form-check">
- <input class="form-check-input" type="radio" name="communication_type" id="communication_type3" value="comment"/>
- <label class="form-check-label" for="communication_type3">
- Allow comments
- </label>
- </div>
+ <t t-jquery=".o_wslide_channel_communication_type" t-operation="after">
+ <div class="form-group row">
+ <div class="col-sm-2">Communication</div>
+ <div class="col-sm-10">
+ <div class="form-check">
+ <input class="form-check-input" type="checkbox" id="link_forum" name="link_forum"/>
+ <label class="form-check-label" for="link_forum">Create a Forum</label>
</div>
</div>
- </fieldset>
+ </div>
</t>
</t>
</templates>
diff --git a/addons/website_slides_forum/views/website_slides_forum_templates.xml b/addons/website_slides_forum/views/website_slides_forum_templates.xml
index 2c47c9e759c..b0342ec4077 100644
--- a/addons/website_slides_forum/views/website_slides_forum_templates.xml
+++ b/addons/website_slides_forum/views/website_slides_forum_templates.xml
@@ -3,9 +3,9 @@
<template id="website_slides_forum_header" inherit_id="website_forum.header">
<xpath expr="//div[hasclass('container')]" position="before">
<t t-if="forum.slide_channel_id">
- <div class="row" style="background-color: #66445e; height: 48px;">
- <div class="container o_wprofile_navtabs_container">
- <ul class="nav nav-tabs o_wprofile_nav_tabs" role="tablist" id="profile_extra_info_tablist">
+ <div class="row o_wforum_elearning_navtabs_container">
+ <div class="container">
+ <ul class="nav nav-tabs o_wprofile_nav_tabs mt-0 flex-nowrap" role="tablist" id="profile_extra_info_tablist">
<li class="nav-item">
<a t-att-href="'/slides/%s%s?%s' % (slug(forum.slide_channel_id), '/%s' % slug(category) if category else '', keep_query('debug'))"
t-att-class="'nav-link o_wprofile_navlink'" style="border-left: 0px">
diff --git a/addons/website_slides_survey/__manifest__.py b/addons/website_slides_survey/__manifest__.py
index 71d793d7e6a..3ccd9168ece 100644
--- a/addons/website_slides_survey/__manifest__.py
+++ b/addons/website_slides_survey/__manifest__.py
@@ -14,7 +14,8 @@
'views/slide_channel_views.xml',
'views/slide_slide_views.xml',
'views/website_slides_templates_course.xml',
- 'views/website_slides_templates.xml',
+ 'views/website_slides_templates_lesson.xml',
+ 'views/website_slides_templates_lesson_fullscreen.xml',
'views/website_slides_templates_homepage.xml',
'views/survey_templates.xml',
'views/website_profile.xml',
diff --git a/addons/website_slides_survey/controllers/slides.py b/addons/website_slides_survey/controllers/slides.py
index c232c865474..b65b3c3333b 100644
--- a/addons/website_slides_survey/controllers/slides.py
+++ b/addons/website_slides_survey/controllers/slides.py
@@ -7,6 +7,7 @@ from odoo.http import request
class WebsiteSlides(WebsiteSlides):
+
def _get_valid_slide_post_values(self):
result = super(WebsiteSlides, self)._get_valid_slide_post_values()
result.append('survey_id')
@@ -24,10 +25,12 @@ class WebsiteSlides(WebsiteSlides):
This is used in the context of a website_publisher designing a course."""
result = super(WebsiteSlides, self)._get_slide_detail(slide)
if not request.env.user._is_public() and slide.slide_type == 'certification' and slide.survey_id:
+ result['certification_test_entry'] = not slide.channel_id.is_member
+ result['certification_done'] = False
if slide.channel_id.is_member:
user_membership_id_sudo = slide.user_membership_id.sudo()
quizz_passed = user_membership_id_sudo.survey_quizz_passed
- result['quizz_passed'] = quizz_passed
+ result['certification_done'] = quizz_passed
result['survey_id'] = slide.survey_id.id
if not quizz_passed:
last_user_input = next(user_input for user_input in user_membership_id_sudo.user_input_ids.sorted(
@@ -35,8 +38,6 @@ class WebsiteSlides(WebsiteSlides):
))
result['certification_url'] = last_user_input._get_survey_url()
else:
- result['quizz_passed'] = False
- result['test_entry'] = True
user_input = slide.survey_id._create_answer(
partner=request.env.user.partner_id,
check_attempts=False,
diff --git a/addons/website_slides_survey/data/slide_slide_demo.xml b/addons/website_slides_survey/data/slide_slide_demo.xml
index 588ddba7511..fe4642fbaf5 100644
--- a/addons/website_slides_survey/data/slide_slide_demo.xml
+++ b/addons/website_slides_survey/data/slide_slide_demo.xml
@@ -1,14 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
-<odoo><data noupdate="0">
- <record id="slide_slide_demo_5_2" model="slide.slide">
+<odoo><data noupdate="1">
+
+ <!-- CHANNEL 5: Basics of Furniture Creation -->
+ <!-- ======================================= -->
+ <record id="slide_slide_demo_5_4" model="slide.slide">
<field name="name">Furniture Creation Certification</field>
+ <field name="sequence">5</field>
+ <field name="image" type="base64" file="website_slides/static/src/img/channel_demo_furniture.jpg"/>
<field name="slide_type">certification</field>
<field name="channel_id" ref="website_slides.slide_channel_demo_5_furn2"/>
<field name="category_id" ref="website_slides.slide_category_demo_5_0"/>
<field name="survey_id" ref="website_slides_survey.furniture_certification"/>
<field name="website_published" eval="True"/>
<field name="is_preview" eval="False"/>
- <field name="image" type="base64" file="website_slides/static/src/img/channel_demo_furniture.jpg"/>
<field name="description">Now that you have completed the course, it's time to test your knowledge!</field>
</record>
+
+ <!-- CHANNEL 6: DIY Furniture -->
+ <!-- ======================================= -->
+ <record id="slide_slide_demo_6_0" model="slide.slide">
+ <field name="name">DIY Furniture Certification</field>
+ <field name="sequence">1</field>
+ <field name="image" type="base64" file="website_slides/static/src/img/channel_demo_furniture_3.jpg"/>
+ <field name="slide_type">certification</field>
+ <field name="channel_id" ref="website_slides.slide_channel_demo_6_furn3"/>
+ <field name="category_id" eval="False"/>
+ <field name="survey_id" ref="website_slides_survey.furniture_certification"/>
+ <field name="website_published" eval="True"/>
+ <field name="is_preview" eval="False"/>
+ <field name="description">It's time to test your knowledge!</field>
+ </record>
+
</data></odoo>
diff --git a/addons/website_slides_survey/models/slide_slide.py b/addons/website_slides_survey/models/slide_slide.py
index 5165589533f..ef6122a4f40 100644
--- a/addons/website_slides_survey/models/slide_slide.py
+++ b/addons/website_slides_survey/models/slide_slide.py
@@ -20,6 +20,19 @@ class SlidePartnerRelation(models.Model):
for record in self:
record.survey_quizz_passed = record in passed_slide_partners
+ @api.model_create_multi
+ def create(self, vals_list):
+ for vals in vals_list:
+ if vals.get('survey_quizz_passed'):
+ vals['completed'] = True
+ return super(SlidePartnerRelation, self).create(vals_list)
+
+ @api.multi
+ def _write(self, vals):
+ if vals.get('survey_quizz_passed'):
+ vals['completed'] = True
+ return super(SlidePartnerRelation, self)._write(vals)
+
class Slide(models.Model):
_inherit = 'slide.slide'
@@ -32,10 +45,10 @@ class Slide(models.Model):
('check_certification_preview', "CHECK(slide_type != 'certification' OR is_preview = False)", "A slide of type certification cannot be previewed."),
]
- def _action_set_viewed(self, target_partner):
+ def _action_set_viewed(self, target_partner, quiz_attempts_inc=False):
""" If the slide viewed is a certification, we initialize the first survey.user_input
for the current partner. """
- new_slide_partners = super(Slide, self)._action_set_viewed(target_partner)
+ new_slide_partners = super(Slide, self)._action_set_viewed(target_partner, quiz_attempts_inc=quiz_attempts_inc)
certification_slides = self.search([
('id', 'in', new_slide_partners.mapped('slide_id').ids),
('slide_type', '=', 'certification'),
diff --git a/addons/website_slides_survey/static/src/xml/website_slides_fullscreen.xml b/addons/website_slides_survey/static/src/xml/website_slides_fullscreen.xml
index 97f35fc3a32..c5d22bfc46f 100644
--- a/addons/website_slides_survey/static/src/xml/website_slides_fullscreen.xml
+++ b/addons/website_slides_survey/static/src/xml/website_slides_fullscreen.xml
@@ -2,9 +2,14 @@
<templates>
<t t-extend="website.slides.fullscreen">
<t t-jquery=".o_wslides_fs_content" t-operation="append">
- <div class="o_wslides_fs_certification" t-if="slide.slide_type == 'certification'">
+ <div class="o_wslides_fs_certification" t-if="slide.slide_type == 'certification' &amp;&amp; !slide.certificationDone">
<a class="btn btn-primary" t-att-href="'/slides/slide/' + slide.slug" target="new"><i class="fa fa-trophy"/> Pass Certification</a>
</div>
+ <div class="o_wslides_fs_certification" t-if="slide.slide_type == 'certification' &amp;&amp; slide.certificationDone">
+ <a role="button" t-att-data-survey_id="slide.certificationId" class="btn btn-primary o_wslides_survey_download_certification" href="#">
+ <i class="fa fa-fw fa-trophy" role="img" aria-label="Download certification" title="Download certification"/> Download certification
+ </a>
+ </div>
</t>
</t>
diff --git a/addons/website_slides_survey/views/website_profile.xml b/addons/website_slides_survey/views/website_profile.xml
index 68010aa776a..6b76420d3ac 100644
--- a/addons/website_slides_survey/views/website_profile.xml
+++ b/addons/website_slides_survey/views/website_profile.xml
@@ -4,7 +4,7 @@
<xpath expr="//div[@id='profile_about_badge']" position="before">
<t t-if="channel">
<div class="mb32">
- <h4><i class="fa fa-trophy"/> Certifications</h4>
+ <h5 class="border-bottom pb-1">Certifications</h5>
<t t-call="website_slides_survey.display_certificate"/>
</div>
</t>
@@ -38,19 +38,21 @@
</div>
</t>
<t t-else="">
- <b>No certifications yet!</b>
+ <p class="text-muted">No certifications yet!</p>
</t>
</template>
<template id="top3_user_card" inherit_id="website_profile.top3_user_card">
- <xpath expr="//div[@id='top3_user_badge_count']" position="after">
- <div class="col" style="border-left: 1px solid lightgrey"><span><span t-esc="user['certification_count']"/> Certifications</span></div>
+ <xpath expr="//div[hasclass('o_wprofile_top3_card_footer')]//div[last()]" position="after">
+ <div class="col py-3"><b t-esc="user['certification_count']"/> <span class="text-muted">Certifications</span></div>
</xpath>
</template>
<template id="all_user_card" inherit_id="website_profile.all_user_card">
- <xpath expr="//div[@id='all_user_badge_count']" position="after">
- <div class="col"><span><span t-esc="user['certification_count']"/> Certifications</span></div>
+ <xpath expr="//td[hasclass('all_user_badge_count')]" position="after">
+ <td class="align-middle text-right pr-3 text-nowrap all_user_certification_count">
+ <b t-esc="user['certification_count']"/> <span class="text-muted small font-weight-bold">Certifications</span>
+ </td>
</xpath>
</template>
</data></odoo>
diff --git a/addons/website_slides_survey/views/website_slides_templates_course.xml b/addons/website_slides_survey/views/website_slides_templates_course.xml
index 95cef917fbd..fc0a2cba672 100644
--- a/addons/website_slides_survey/views/website_slides_templates_course.xml
+++ b/addons/website_slides_survey/views/website_slides_templates_course.xml
@@ -3,7 +3,7 @@
<data>
<template id="slide_icon_inherit_survey" inherit_id="website_slides.slide_icon">
<xpath expr="//i[last()]" position="after">
- <i t-if="slide.slide_type == 'certification'" class="fa fa-trophy mr-2 text-muted"></i>
+ <i t-if="slide.slide_type == 'certification'" t-att-class="'fa fa-trophy %s' % icon_class"></i>
</xpath>
</template>
</data>
diff --git a/addons/website_slides_survey/views/website_slides_templates.xml b/addons/website_slides_survey/views/website_slides_templates_lesson.xml
index 84d9b348fec..c6233535bed 100644
--- a/addons/website_slides_survey/views/website_slides_templates.xml
+++ b/addons/website_slides_survey/views/website_slides_templates_lesson.xml
@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
- <template id="slide_detail_view_inherit_survey" inherit_id="website_slides.slide_detail_view">
- <xpath expr="//div[hasclass('o_w_slides_slide_detail_container')]/div[hasclass('row')]" position="before">
- <t t-if="not test_entry" t-set="begin_certification_label" t-value="'Begin certification'" />
- <t t-if="test_entry" t-set="begin_certification_label" t-value="'Test certification'" />
- <div t-if="slide.slide_type == 'certification' and not quizz_passed" class="mb8">
+ <template id="slide_content_detailed" inherit_id="website_slides.slide_content_detailed">
+ <xpath expr="//div[hasclass('o_wslides_lesson_content_type')]" position="inside">
+ <t t-if="not certification_test_entry" t-set="begin_certification_label" t-value="'Begin certification'" />
+ <t t-if="certification_test_entry" t-set="begin_certification_label" t-value="'Test certification'" />
+ <div t-if="slide.slide_type == 'certification' and not certification_done" class="col mt32 mb8 d-flex justify-content-center">
<a role="button"
class="btn btn-primary btn-lg"
t-att-href="certification_url">
@@ -13,7 +13,7 @@
<t t-esc="begin_certification_label" />
</a>
</div>
- <div t-if="slide.slide_type == 'certification' and quizz_passed" class="mb8">
+ <div t-if="slide.slide_type == 'certification' and certification_done" class="col mt32 mb8 d-flex justify-content-center">
<a role="button"
t-att-data-survey_id="survey_id"
class="btn btn-primary btn-lg o_wslides_survey_download_certification"
diff --git a/addons/website_slides_survey/views/website_slides_templates_lesson_fullscreen.xml b/addons/website_slides_survey/views/website_slides_templates_lesson_fullscreen.xml
new file mode 100644
index 00000000000..d42674336ac
--- /dev/null
+++ b/addons/website_slides_survey/views/website_slides_templates_lesson_fullscreen.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo><data>
+
+ <template id="slide_fullscreen_sidebar_category" inherit_id="website_slides.slide_fullscreen_sidebar_category">
+ <xpath expr="//div[@t-attf-index='#{i}']" position="attributes">
+ <attribute name="t-att-data-certification-done">channel_progress[course_slide.id].get('survey_quizz_passed')</attribute>
+ <attribute name="t-att-data-certification-id">course_slide.survey_id.id</attribute>
+ </xpath>
+ </template>
+
+</data></odoo>
diff --git a/addons/website_theme_install/tests/__init__.py b/addons/website_theme_install/tests/__init__.py
index 28efb5bbfb5..ae189cba198 100644
--- a/addons/website_theme_install/tests/__init__.py
+++ b/addons/website_theme_install/tests/__init__.py
@@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from . import test_theme
from . import test_views
diff --git a/addons/website_theme_install/tests/test_theme.py b/addons/website_theme_install/tests/test_theme.py
new file mode 100644
index 00000000000..845474d67d2
--- /dev/null
+++ b/addons/website_theme_install/tests/test_theme.py
@@ -0,0 +1,14 @@
+# coding: utf-8
+from odoo.tests import common
+
+
+class TestTheme(common.TransactionCase):
+
+ def test_theme_remove_working(self):
+ """ This test ensure theme can be removed.
+ Theme removal is also the first step during theme installation.
+ """
+ theme_common_module = self.env['ir.module.module'].search([('name', '=', 'theme_default')])
+ website = self.env['website'].get_current_website()
+ website.theme_id = theme_common_module.id
+ self.env['ir.module.module']._theme_remove(website)
diff --git a/doc/_static/banners/email_gateway.jpg b/doc/_static/banners/email_gateway.jpg
new file mode 100644
index 00000000000..b9301e27040
--- /dev/null
+++ b/doc/_static/banners/email_gateway.jpg
Binary files differ
diff --git a/doc/_static/banners/email_gateway.small.jpg b/doc/_static/banners/email_gateway.small.jpg
new file mode 100644
index 00000000000..435c312eb52
--- /dev/null
+++ b/doc/_static/banners/email_gateway.small.jpg
Binary files differ
diff --git a/doc/cla/corporate/adhoc.md b/doc/cla/corporate/adhoc.md
index 428f46b0056..ca781cae7a5 100644
--- a/doc/cla/corporate/adhoc.md
+++ b/doc/cla/corporate/adhoc.md
@@ -14,3 +14,4 @@ Damián Soriano ds@adhoc.com.ar https://github.com/damiansoriano
Juan José Scarafía jjs@adhoc.com.ar https://github.com/jjscarafia
Nicolás Mac Rouillon nmr@adhoc.com.ar https://github.com/nicomacr
Katherine Zaoral kz@adhoc.com.ar https://github.com/zaoral
+Valentino Defelice vd@adhoc.com.ar https://github.com/ValentinoDefelice
diff --git a/doc/cla/corporate/savoirfairelinux.md b/doc/cla/corporate/savoirfairelinux.md
index 6dca085c4ce..913e317cb07 100644
--- a/doc/cla/corporate/savoirfairelinux.md
+++ b/doc/cla/corporate/savoirfairelinux.md
@@ -21,6 +21,8 @@ Julien Jezequel-Breard julien.jezequel-breard@savoirfairelinux.com https://githu
Quentin Lavallée-Bourdeau quentin.lavallee@savoirfairelinux.com https://github.com/qtiplb
Rim Ben Dhaou rim.bendhaou@savoirfairelinux.com https://github.com/rimbendhaou
Yasmine El Mrini yasmine.elmrini@savoirfairelinux.com https://github.com/yasmineelmrini
+Luis Garcia Ruiz luis.garcia@savoirfairelinux.com https://github.com/eilst
+Dimitar Apostolov dimitar.apostolov@savoirfairelinux.com https://github.com/dapostolov-sfl
Jérome Boisvert-Chouinard jerome.boisvertchouinard@savoirfairelinux.com https://github.com/jbchouinard (up to 2017-10-17)
Julie Moussu julie.mousse@savoirfairelinux.com https://github.com/JulieSFL (up to 2017-10-17)
Pierre Lamarche pierre.lamarche@savoirfairelinux.com https://github.com/plamarche (up to 2017-10-17)
diff --git a/doc/cla/individual/mjabuz.md b/doc/cla/individual/mjabuz.md
new file mode 100644
index 00000000000..55e9589f992
--- /dev/null
+++ b/doc/cla/individual/mjabuz.md
@@ -0,0 +1,11 @@
+Bangladesh, 16 February, 2019
+
+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,
+
+Abu Zafar azmikbal@gmail.com https://github.com/MjAbuz
diff --git a/doc/howtos/backend/exercise-calendar b/doc/howtos/backend/exercise-calendar
index 8f3b59658e8..1ef0cbe2ca7 100644
--- a/doc/howtos/backend/exercise-calendar
+++ b/doc/howtos/backend/exercise-calendar
@@ -21,7 +21,7 @@ diff --git a/openacademy/models.py b/openacademy/models.py
@api.depends('seats', 'attendee_ids')
def _taken_seats(self):
-@@ -83,6 +86,30 @@ class Session(models.Model):
+@@ -83,6 +86,27 @@ class Session(models.Model):
},
}
@@ -34,9 +34,8 @@ diff --git a/openacademy/models.py b/openacademy/models.py
+
+ # Add duration to start_date, but: Monday + 5 days = Saturday, so
+ # subtract one second to get on Friday instead
-+ start = fields.Datetime.from_string(r.start_date)
+ duration = timedelta(days=r.duration, seconds=-1)
-+ r.end_date = start + duration
++ r.end_date = r.start_date + duration
+
+ def _set_end_date(self):
+ for r in self:
@@ -45,9 +44,7 @@ diff --git a/openacademy/models.py b/openacademy/models.py
+
+ # Compute the difference between dates, but: Friday - Monday = 4 days,
+ # so add one day to get 5 days instead
-+ start_date = fields.Datetime.from_string(r.start_date)
-+ end_date = fields.Datetime.from_string(r.end_date)
-+ r.duration = (end_date - start_date).days + 1
++ r.duration = (r.end_date - r.start_date).days + 1
+
@api.constrains('instructor_id', 'attendee_ids')
def _check_instructor_not_in_attendees(self):
diff --git a/doc/howtos/backend/exercise-gantt b/doc/howtos/backend/exercise-gantt
index 1e4aedd9624..54d3cb5b37f 100644
--- a/doc/howtos/backend/exercise-gantt
+++ b/doc/howtos/backend/exercise-gantt
@@ -15,9 +15,9 @@ diff --git a/openacademy/models.py b/openacademy/models.py
@api.depends('seats', 'attendee_ids')
def _taken_seats(self):
for r in self:
-@@ -110,6 +113,15 @@ class Session(models.Model):
- end_date = fields.Datetime.from_string(r.end_date)
- r.duration = (end_date - start_date).days + 1
+@@ -107,6 +110,15 @@ class Session(models.Model):
+ # so add one day to get 5 days instead
+ r.duration = (r.end_date - r.start_date).days + 1
+ @api.depends('duration')
+ def _get_hours(self):
diff --git a/doc/howtos/backend/exercise-graph b/doc/howtos/backend/exercise-graph
index 39d06f74944..8fb2a46dc1f 100644
--- a/doc/howtos/backend/exercise-graph
+++ b/doc/howtos/backend/exercise-graph
@@ -15,7 +15,7 @@ diff --git a/openacademy/models.py b/openacademy/models.py
@api.depends('seats', 'attendee_ids')
def _taken_seats(self):
for r in self:
-@@ -122,6 +125,11 @@ class Session(models.Model):
+@@ -119,6 +122,11 @@ class Session(models.Model):
for r in self:
r.duration = r.hours / 24
diff --git a/doc/howtos/backend/exercise-translations b/doc/howtos/backend/exercise-translations
index 08c013783cf..3cd57d2fd33 100644
--- a/doc/howtos/backend/exercise-translations
+++ b/doc/howtos/backend/exercise-translations
@@ -48,7 +48,7 @@ diff --git a/openacademy/models.py b/openacademy/models.py
},
}
-@@ -135,4 +135,4 @@ class Session(models.Model):
+@@ -132,4 +132,4 @@ class Session(models.Model):
def _check_instructor_not_in_attendees(self):
for r in self:
if r.instructor_id and r.instructor_id in r.attendee_ids:
diff --git a/doc/setup.rst b/doc/setup.rst
index 99b4bd3ad51..7b03c885e77 100644
--- a/doc/setup.rst
+++ b/doc/setup.rst
@@ -12,3 +12,4 @@ Setting Up
setup/update
setup/cdn
setup/enterprise
+ setup/email_gateway
diff --git a/doc/setup/email_gateway.rst b/doc/setup/email_gateway.rst
new file mode 100644
index 00000000000..3567ad9c9bf
--- /dev/null
+++ b/doc/setup/email_gateway.rst
@@ -0,0 +1,43 @@
+:banner: banners/email_gateway.jpg
+
+==================
+Odoo email gateway
+==================
+
+The Odoo mail gateway allows you to inject directly all the received emails in Odoo.
+
+Its principle is straightforward: your SMTP server executes the "mailgate" script for every new incoming email.
+
+The script takes care of connecting to your Odoo database through XML-RPC, and send the emails via the MailThread.message_process() feature.
+
+Prerequisites
+-------------
+- Administrator access to the Odoo database.
+- Your own mail server such as Postfix or Exim.
+- Technical knowledge on how to configure an email server.
+
+For Postfix
+-----------
+In you alias config (/etc/aliases):
+
+.. code-block:: text
+
+ email@address: "|/odoo-directory/addons/mail/static/scripts/odoo-mailgate.py -d <database-name> -u <userid> -p <password>"
+
+.. note:: Resources
+ - `Postfix <http://www.postfix.org/documentation.html>`
+ - `Postfix aliases <http://www.postfix.org/aliases.5.html>`
+ - `Postfix virtual <http://www.postfix.org/virtual.8.html>`
+
+
+For Exim
+--------
+.. code-block:: text
+
+ *: |/odoo-directory/addons/mail/static/scripts/odoo-mailgate.py -d <database-name> -u <userid> -p <password>
+
+.. note:: Resources
+ - `Exim <https://www.exim.org/docs.html>`
+
+
+.. note:: If you don't have access/manage your email server, use `inbound messages<https://www.odoo.com/documentation/user/12.0/discuss/email_servers.html#how-to-manage-inbound-messages>`.
diff --git a/odoo/addons/base/models/ir_model.py b/odoo/addons/base/models/ir_model.py
index e5013de5d5e..570de543c22 100644
--- a/odoo/addons/base/models/ir_model.py
+++ b/odoo/addons/base/models/ir_model.py
@@ -199,6 +199,9 @@ class IrModel(models.Model):
# prevent screwing up fields that depend on these models' fields
model.field_id._prepare_update()
+ # delete fields whose comodel is being removed
+ self.env['ir.model.fields'].search([('relation', 'in', self.mapped('model'))]).unlink()
+
self._drop_table()
res = super(IrModel, self).unlink()
@@ -725,7 +728,7 @@ class IrModelFields(models.Model):
if vals.get('name', item.name) != item.name:
# We need to rename the field
item._prepare_update()
- if item.ttype in ('one2many', 'many2many'):
+ if item.ttype in ('one2many', 'many2many', 'binary'):
# those field names are not explicit in the database!
pass
else:
diff --git a/odoo/addons/base/models/ir_rule.py b/odoo/addons/base/models/ir_rule.py
index bc6c0233b87..092641e7f0d 100644
--- a/odoo/addons/base/models/ir_rule.py
+++ b/odoo/addons/base/models/ir_rule.py
@@ -147,6 +147,8 @@ class IrRule(models.Model):
group_domains.append(dom)
# combine global domains and group domains
+ if not group_domains:
+ return expression.AND(global_domains)
return expression.AND(global_domains + [expression.OR(group_domains)])
@api.model
diff --git a/odoo/addons/base/models/qweb.py b/odoo/addons/base/models/qweb.py
index fa1b20e147f..af12793edd9 100644
--- a/odoo/addons/base/models/qweb.py
+++ b/odoo/addons/base/models/qweb.py
@@ -31,6 +31,8 @@ try:
except ImportError:
astor = None
+from odoo.tools.parse_version import parse_version
+
unsafe_eval = eval
_logger = logging.getLogger(__name__)
@@ -161,7 +163,7 @@ class QWebException(Exception):
return str(self)
# Avoid DeprecationWarning while still remaining compatible with werkzeug pre-0.9
-escape = (lambda text: _escape(text, quote=True)) if getattr(werkzeug, '__version__', '0.0') < '0.9.0' else _escape
+escape = (lambda text: _escape(text, quote=True)) if parse_version(getattr(werkzeug, '__version__', '0.0')) < parse_version('0.9.0') else _escape
def foreach_iterator(base_ctx, enum, name):
ctx = base_ctx.copy()
@@ -1471,7 +1473,7 @@ class QWeb(object):
)
if call_options:
- # update this dict with the content of `t-call-options`
+ # update this dict with the content of `t-call-options`
content.extend([
# options_.update(template options)
ast.Expr(ast.Call(
@@ -1482,7 +1484,56 @@ class QWeb(object):
),
args=[self._compile_expr(call_options)],
keywords=[], starargs=None, kwargs=None
- ))
+ )),
+
+ # if options.get('lang') != options_.get('lang'):
+ # self = self.with_context(lang=options.get('lang'))
+ ast.If(
+ test=ast.Compare(
+ left=ast.Call(
+ func=ast.Attribute(
+ value=ast.Name(id='options', ctx=ast.Load()),
+ attr='get',
+ ctx=ast.Load()
+ ),
+ args=[ast.Str("lang")], keywords=[],
+ starargs=None, kwargs=None
+ ),
+ ops=[ast.NotEq()],
+ comparators=[ast.Call(
+ func=ast.Attribute(
+ value=ast.Name(id=name_options, ctx=ast.Load()),
+ attr='get',
+ ctx=ast.Load()
+ ),
+ args=[ast.Str("lang")], keywords=[],
+ starargs=None, kwargs=None
+ )]
+ ),
+ body=[
+ ast.Assign(
+ targets=[ast.Name(id='self', ctx=ast.Store())],
+ value=ast.Call(
+ func=ast.Attribute(
+ value=ast.Name(id='self', ctx=ast.Load()),
+ attr='with_context',
+ ctx=ast.Load()
+ ),
+ args=[],
+ keywords=[ast.keyword('lang', ast.Call(
+ func=ast.Attribute(
+ value=ast.Name(id=name_options, ctx=ast.Load()),
+ attr='get',
+ ctx=ast.Load()
+ ),
+ args=[ast.Str("lang")], keywords=[],
+ starargs=None, kwargs=None
+ ))],
+ starargs=None, kwargs=None
+ )
+ )],
+ orelse=[],
+ )
])
if nsmap:
@@ -1594,32 +1645,26 @@ class QWeb(object):
def _compile_expr0(self, expr):
if expr == "0":
- # values.get(0) and u''.join(values[0])
- return ast.BoolOp(
- op=ast.And(),
- values=[
- ast.Call(
- func=ast.Attribute(
- value=ast.Name(id='values', ctx=ast.Load()),
- attr='get',
- ctx=ast.Load()
- ),
- args=[ast.Num(0)], keywords=[],
- starargs=None, kwargs=None
+ # u''.join(values.get(0, []))
+ return ast.Call(
+ func=ast.Attribute(
+ value=ast.Str(u''),
+ attr='join',
+ ctx=ast.Load()
+ ),
+ args=[
+ ast.Call(
+ func=ast.Attribute(
+ value=ast.Name(id='values', ctx=ast.Load()),
+ attr='get',
+ ctx=ast.Load()
),
- ast.Call(
- func=ast.Attribute(
- value=ast.Str(u''),
- attr='join',
- ctx=ast.Load()
- ),
- args=[
- self._values_var(ast.Num(0), ctx=ast.Load())
- ],
- keywords=[], starargs=None, kwargs=None
- )
- ]
- )
+ args=[ast.Num(0), ast.List(elts=[], ctx=ast.Load())], keywords=[],
+ starargs=None, kwargs=None
+ )
+ ],
+ keywords=[], starargs=None, kwargs=None
+ )
return self._compile_expr(expr)
def _compile_format(self, f):
diff --git a/odoo/addons/base/models/res_users.py b/odoo/addons/base/models/res_users.py
index 6077e76877d..937ec8b6dd4 100644
--- a/odoo/addons/base/models/res_users.py
+++ b/odoo/addons/base/models/res_users.py
@@ -107,6 +107,11 @@ class Groups(models.Model):
('name_uniq', 'unique (category_id, name)', 'The name of the group must be unique within an application!')
]
+ @api.multi
+ @api.constrains('users')
+ def _check_one_user_type(self):
+ self.mapped('users')._check_one_user_type()
+
@api.depends('category_id.name', 'name')
def _compute_full_name(self):
# Important: value must be stored in environment of group, not group1!
@@ -402,6 +407,13 @@ class Users(models.Model):
raise ValidationError(_('The "App Switcher" action cannot be selected as home action.'))
@api.multi
+ @api.constrains('groups_id')
+ def _check_one_user_type(self):
+ for user in self:
+ if len(user.groups_id.filtered(lambda x: x.category_id.xml_id == 'base.module_category_user_type')) > 1:
+ raise ValidationError(_('The user cannot have more than one user types.'))
+
+ @api.multi
def toggle_active(self):
for user in self:
if not user.active and not user.partner_id.active:
@@ -954,7 +966,14 @@ class UsersImplied(models.Model):
if 'groups_id' in values:
# complete 'groups_id' with implied groups
user = self.new(values)
- gs = user.groups_id | user.groups_id.mapped('trans_implied_ids')
+ group_public = self.env.ref('base.group_public', raise_if_not_found=False)
+ group_portal = self.env.ref('base.group_portal', raise_if_not_found=False)
+ if group_public and group_public in user.groups_id:
+ gs = self.env.ref('base.group_public') | self.env.ref('base.group_public').trans_implied_ids
+ elif group_portal and group_portal in user.groups_id:
+ gs = self.env.ref('base.group_portal') | self.env.ref('base.group_portal').trans_implied_ids
+ else:
+ gs = user.groups_id | user.groups_id.mapped('trans_implied_ids')
values['groups_id'] = type(self).groups_id.convert_to_write(gs, user.groups_id)
return super(UsersImplied, self).create(vals_list)
@@ -966,9 +985,9 @@ class UsersImplied(models.Model):
for user in self.with_context({}):
if not user.has_group('base.group_user'):
vals = {'groups_id': [(5, 0, 0)] + values['groups_id']}
- else:
- gs = set(concat(g.trans_implied_ids for g in user.groups_id))
- vals = {'groups_id': [(4, g.id) for g in gs]}
+ super(UsersImplied, user).write(vals)
+ gs = set(concat(g.trans_implied_ids for g in user.groups_id))
+ vals = {'groups_id': [(4, g.id) for g in gs]}
super(UsersImplied, user).write(vals)
return res
@@ -1243,7 +1262,12 @@ class UsersView(models.Model):
values[f] = get_boolean_group(f) in gids
elif is_selection_groups(f):
selected = [gid for gid in get_selection_groups(f) if gid in gids]
- values[f] = selected and selected[-1] or False
+ # if 'Internal User' is in the group, this is the "User Type" group
+ # and we need to show 'Internal User' selected, not Public/Portal.
+ if self.env.ref('base.group_user').id in selected:
+ values[f] = self.env.ref('base.group_user').id
+ else:
+ values[f] = selected and selected[-1] or False
@api.model
def fields_get(self, allfields=None, attributes=None):
diff --git a/odoo/addons/base/static/src/js/res_config_settings.js b/odoo/addons/base/static/src/js/res_config_settings.js
index 4eae5828115..71841ccfe3f 100644
--- a/odoo/addons/base/static/src/js/res_config_settings.js
+++ b/odoo/addons/base/static/src/js/res_config_settings.js
@@ -10,7 +10,6 @@ var FormRenderer = require('web.FormRenderer');
var view_registry = require('web.view_registry');
var QWeb = core.qweb;
-var _t = core._t;
var BaseSettingRenderer = FormRenderer.extend({
events: _.extend({}, FormRenderer.prototype.events, {
@@ -36,20 +35,6 @@ var BaseSettingRenderer = FormRenderer.extend({
/**
* @override
- * overridden to show statusbar buttons and a message if there are changes
- */
- confirmChange: function () {
- var self = this;
- return this._super.apply(this, arguments).then(function () {
- if (self.$('.o_statusbar_buttons').hasClass('d-none')) {
- self.$('.o_statusbar_buttons')
- .append($('<span/>', {text: _t("There are unsaved changes"), class: 'text-muted ml-2'}))
- .removeClass('d-none');
- }
- });
- },
- /**
- * @override
*/
on_attach_callback: function () {
this._super.apply(this, arguments);
@@ -287,7 +272,7 @@ var BaseSettingRenderer = FormRenderer.extend({
self._initModules();
self._renderLeftPanel();
self._initSearch();
- self.$('.o_statusbar_buttons').addClass('d-none');
+
if (config.device.isMobile) {
self._enableSwipe();
}
diff --git a/odoo/addons/base/static/tests/base_settings_tests.js b/odoo/addons/base/static/tests/base_settings_tests.js
index 8dc85e352e6..3adcdbcec89 100644
--- a/odoo/addons/base/static/tests/base_settings_tests.js
+++ b/odoo/addons/base/static/tests/base_settings_tests.js
@@ -179,41 +179,5 @@ QUnit.module('base_settings_tests', {
form.destroy();
});
- QUnit.test('settings view shows statusbar buttons only if there are changes to save', async function (assert) {
- assert.expect(5);
-
- var form = await createView({
- View: BaseSettingsView,
- model: 'project',
- data: this.data,
- arch: '<form string="Settings" class="oe_form_configuration o_base_settings">' +
- '<header>' +
- '<button string="Save" type="object" name="execute" class="oe_highlight" />' +
- '<button string="Discard" type="object" name="cancel" special="cancel" />'+
- '</header>' +
- '<div class="o_setting_container">' +
- '<div class="settings_tab"/>' +
- '<div class="settings">' +
- '<div class="notFound o_hidden">No Record Found</div>' +
- '<div class="app_settings_block" string="Base Setting" data-key="base-setting">' +
- '<field name="bar"/>Make Changes' +
- '</div>' +
- '</div>' +
- '</div>' +
- '</form>',
- });
-
- testUtils.mock.intercept(form, "field_changed", function (event) {
- assert.ok("field changed");
- }, true);
-
- assert.containsNone(form, '.o_field_boolean input:checked', "checkbox should not be checked");
- assert.hasClass(form.$('.o_statusbar_buttons'), 'd-none', "statusbar buttons should not be shown");
- await testUtils.dom.click(form.$("input[type='checkbox']"));
- assert.strictEqual(form.$('.o_field_boolean input:checked').length, 1,"checkbox should be checked");
- assert.isVisible(form.$('.o_statusbar_buttons'), "statusbar buttons should be shown");
- form.destroy();
- });
-
});
});
diff --git a/odoo/addons/base/static/xls/res_partner.xls b/odoo/addons/base/static/xls/res_partner.xls
index 8f6640c94b6..dbdec5bf229 100644
--- a/odoo/addons/base/static/xls/res_partner.xls
+++ b/odoo/addons/base/static/xls/res_partner.xls
Binary files differ
diff --git a/odoo/addons/base/tests/test_expression.py b/odoo/addons/base/tests/test_expression.py
index 9f628054f02..b40035dadd6 100644
--- a/odoo/addons/base/tests/test_expression.py
+++ b/odoo/addons/base/tests/test_expression.py
@@ -640,6 +640,36 @@ class TestExpression(TransactionCase):
'&', ('foo', '=', 2), ('bar', '=', 2)]
self.assertEqual(expression.OR([d1, d2]), expected)
+ def test_proper_combine_unit_leaves(self):
+ # test that unit leaves (TRUE_LEAF, FALSE_LEAF) are properly handled in specific cases
+ false = expression.FALSE_DOMAIN
+ true = expression.TRUE_DOMAIN
+ normal = [('foo', '=', 'bar')]
+ # OR with single FALSE_LEAF
+ expr = expression.OR([false])
+ self.assertEqual(expr, false)
+ # OR with multiple FALSE_LEAF
+ expr = expression.OR([false, false])
+ self.assertEqual(expr, false)
+ # OR with FALSE_LEAF and a normal leaf
+ expr = expression.OR([false, normal])
+ self.assertEqual(expr, normal)
+ # OR with AND of single TRUE_LEAF and normal leaf
+ expr = expression.OR([expression.AND([true]), normal])
+ self.assertEqual(expr, true)
+ # AND with single TRUE_LEAF
+ expr = expression.AND([true])
+ self.assertEqual(expr, true)
+ # AND with multiple TRUE_LEAF
+ expr = expression.AND([true, true])
+ self.assertEqual(expr, true)
+ # AND with TRUE_LEAF and normal leaves
+ expr = expression.AND([true, normal])
+ self.assertEqual(expr, normal)
+ # AND with OR with single FALSE_LEAF and normal leaf
+ expr = expression.AND([expression.OR([false]), normal])
+ self.assertEqual(expr, false)
+
class TestAutoJoin(TransactionCase):
diff --git a/odoo/addons/base/tests/test_qweb.py b/odoo/addons/base/tests/test_qweb.py
index 2a01fa1ca44..d8ce5e48f68 100644
--- a/odoo/addons/base/tests/test_qweb.py
+++ b/odoo/addons/base/tests/test_qweb.py
@@ -501,6 +501,47 @@ class TestQWebNS(TransactionCase):
with self.assertRaises(QWebException, msg=error_msg):
view1.render()
+ def test_render_t_call_propagates_t_lang(self):
+ current_lang = 'en_US'
+ other_lang = 'fr_FR'
+
+ self.env['res.lang'].load_lang(lang=other_lang)
+
+ self.env['res.lang'].search([('code', '=', other_lang)], limit=1).write({
+ 'active': True,
+ 'decimal_point': '*',
+ 'thousands_sep': '/'
+ })
+
+ view1 = self.env['ir.ui.view'].create({
+ 'name': "callee",
+ 'type': 'qweb',
+ 'arch': u"""
+ <t t-name="base.callee">
+ <t t-esc="9000000.00" t-options="{'widget': 'float', 'precision': 2}" />
+ </t>
+ """
+ })
+ self.env['ir.model.data'].create({
+ 'name': 'callee',
+ 'model': 'ir.ui.view',
+ 'module': 'base',
+ 'res_id': view1.id,
+ })
+
+ view2 = self.env['ir.ui.view'].create({
+ 'name': "calling",
+ 'type': 'qweb',
+ 'arch': u"""
+ <t t-name="base.calling">
+ <t t-call="base.callee" t-lang="'%s'" />
+ </t>
+ """ % other_lang
+ })
+
+ rendered = view2.with_context(lang=current_lang).render().strip()
+ self.assertEqual(rendered, b'9/000/000*00')
+
from copy import deepcopy
class FileSystemLoader(object):
diff --git a/odoo/addons/base/tests/test_user_has_group.py b/odoo/addons/base/tests/test_user_has_group.py
index d7e44410104..0151bd2354c 100644
--- a/odoo/addons/base/tests/test_user_has_group.py
+++ b/odoo/addons/base/tests/test_user_has_group.py
@@ -2,6 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests.common import TransactionCase
+from odoo.exceptions import ValidationError
class TestHasGroup(TransactionCase):
@@ -23,6 +24,11 @@ class TestHasGroup(TransactionCase):
'groups_id': [(4, group0.id, 0)]
})
+ self.grp_internal_xml_id = 'base.group_user'
+ self.grp_internal = self.env.ref(self.grp_internal_xml_id)
+ self.grp_portal_xml_id = 'base.group_portal'
+ self.grp_portal = self.env.ref(self.grp_portal_xml_id)
+
def test_env_uid(self):
Users = self.env['res.users'].sudo(self.test_user)
self.assertTrue(
@@ -43,3 +49,97 @@ class TestHasGroup(TransactionCase):
self.test_user.has_group(self.group1),
"the test user shoudl not belong to group1"
)
+
+ def test_portal_creation(self):
+ grp_public = self.env.ref('base.group_public')
+ grp_test_portal_xml_id = 'test_user_has_group.portal_implied_group'
+ grp_test_portal = self.env['res.groups']._load_records([
+ dict(xml_id=grp_test_portal_xml_id, values={'name': 'Test Group Portal'})
+ ])
+ grp_test_internal1 = self.env['res.groups']._load_records([
+ dict(xml_id='test_user_has_group.internal_implied_group1', values={'name': 'Test Group Itnernal 1'})
+ ])
+ grp_test_internal2_xml_id = 'test_user_has_group.internal_implied_group2'
+ grp_test_internal2 = self.env['res.groups']._load_records([
+ dict(xml_id=grp_test_internal2_xml_id, values={'name': 'Test Group Internal 2'})
+ ])
+ self.grp_portal.implied_ids = grp_test_portal
+ grp_test_internal1.implied_ids = self.grp_internal
+ grp_test_internal2.implied_ids = self.grp_internal
+ portal_user = self.env['res.users'].create({
+ 'login': 'portalTest',
+ 'name': 'Portal test',
+ 'sel_groups_%s_%s_%s' % (self.grp_internal.id, self.grp_portal.id, grp_public.id): self.grp_portal.id,
+ 'sel_groups_%s_%s' % (grp_test_internal1.id, grp_test_internal2.id): grp_test_internal2.id,
+ })
+
+ self.assertTrue(
+ portal_user.has_group(self.grp_portal_xml_id),
+ "The portal user should belong to '%s'" % self.grp_portal_xml_id,
+ )
+ self.assertTrue(
+ portal_user.has_group(grp_test_portal_xml_id),
+ "The portal user should belong to '%s'" % grp_test_portal_xml_id,
+ )
+ self.assertFalse(
+ portal_user.has_group(grp_test_internal2_xml_id),
+ "The portal user should not belong to '%s'" % grp_test_internal2_xml_id
+ )
+ self.assertFalse(
+ portal_user.has_group(self.grp_internal_xml_id),
+ "The portal user should not belong to '%s'" % self.grp_internal_xml_id
+ )
+
+ def test_portal_write(self):
+ grp_remove_xml_id = 'test_portal_write.group_to_remove'
+ grp_remove = self.env['res.groups']._load_records([
+ dict(xml_id=grp_remove_xml_id, values={'name': 'Group to remove'})
+ ])
+ portal_user = self.env['res.users'].create({
+ 'login': 'portalTest2',
+ 'name': 'Portal test 2',
+ 'groups_id': [(6, 0, [grp_remove.id])],
+ })
+ grp_test_portal_xml_id = 'test_portal_write.portal_implied_group'
+ grp_test_portal = self.env['res.groups']._load_records([
+ dict(xml_id=grp_test_portal_xml_id, values={'name': 'Test Group Portal'})
+ ])
+ self.grp_portal.implied_ids = grp_test_portal
+ portal_user.write({'groups_id': [(4, self.grp_portal.id, 0)]})
+
+ self.assertFalse(
+ portal_user.has_group(grp_remove_xml_id),
+ "The portal user should not belong to '%s'" % grp_remove_xml_id
+ )
+ self.assertTrue(
+ portal_user.has_group(self.grp_portal_xml_id),
+ "The portal user should belong to '%s'" % self.grp_portal_xml_id,
+ )
+ self.assertTrue(
+ portal_user.has_group(grp_test_portal_xml_id),
+ "The portal user should belong to '%s'" % grp_test_portal_xml_id,
+ )
+
+ def test_two_user_types(self):
+ #Create a user with two groups of user types kind (Internal and Portal)
+ grp_test = self.env['res.groups']._load_records([
+ dict(xml_id='test_two_user_types.implied_groups', values={'name': 'Test Group'})
+ ])
+ grp_test.implied_ids += self.grp_internal
+ grp_test.implied_ids += self.grp_portal
+
+ with self.assertRaises(ValidationError):
+ self.env['res.users'].create({
+ 'login': 'test_two_user_types',
+ 'name': "Test User with two user types",
+ 'groups_id': [(6, 0, [grp_test.id])]
+ })
+
+ #Add a user with portal to the group Internal
+ test_user = self.env['res.users'].create({
+ 'login': 'test_user_portal',
+ 'name': "Test User with two user types",
+ 'groups_id': [(6, 0, [self.grp_portal.id])]
+ })
+ with self.assertRaises(ValidationError):
+ self.grp_internal.users = [(4, test_user.id)]
diff --git a/odoo/addons/base/wizard/base_import_language.py b/odoo/addons/base/wizard/base_import_language.py
index e39c36e89ae..20bb24e7c06 100644
--- a/odoo/addons/base/wizard/base_import_language.py
+++ b/odoo/addons/base/wizard/base_import_language.py
@@ -17,7 +17,7 @@ class BaseLanguageImport(models.TransientModel):
_description = "Language Import"
name = fields.Char('Language Name', required=True)
- code = fields.Char('ISO Code', size=5, required=True,
+ code = fields.Char('ISO Code', size=6, required=True,
help="ISO Language and Country code, e.g. en_US")
data = fields.Binary('File', required=True, attachment=False)
filename = fields.Char('File Name', required=True)
diff --git a/odoo/addons/test_new_api/tests/test_ui.py b/odoo/addons/test_new_api/tests/test_ui.py
index 7291e2b7c3e..5afc2876f4f 100644
--- a/odoo/addons/test_new_api/tests/test_ui.py
+++ b/odoo/addons/test_new_api/tests/test_ui.py
@@ -5,6 +5,13 @@ import odoo.tests
class TestUi(odoo.tests.HttpCase):
def test_01_admin_widget_x2many(self):
+ # FIXME: breaks if too many children of base.menu_tests
+
+ # This tour turns out to be quite sensible to the number of items in
+ # the base.menu_tests: it's specifically sequenced to be lower (after)
+ # the default, but doesn't account for the fact that it could
+ # "fall off" into the "o_extra_menu_items" section if the window is
+ # too small or there are too many items preceding it in the tests menu
self.phantom_js("/web#action=test_new_api.action_discussions",
"odoo.__DEBUG__.services['web_tour.tour'].run('widget_x2many', 100)",
"odoo.__DEBUG__.services['web_tour.tour'].tours.widget_x2many.ready",
diff --git a/odoo/addons/test_testing_utilities/ir.model.access.csv b/odoo/addons/test_testing_utilities/ir.model.access.csv
index 697a7334581..c069b568ad4 100644
--- a/odoo/addons/test_testing_utilities/ir.model.access.csv
+++ b/odoo/addons/test_testing_utilities/ir.model.access.csv
@@ -14,3 +14,5 @@ access_test_testing_utilities_sub2,access_test_testing_utilities_sub2,model_test
access_test_testing_utilities_sub3,access_test_testing_utilities_sub3,model_test_testing_utilities_sub3,,1,0,0,0
access_test_testing_utilities_onchange_parent,access_test_testing_utilities_onchange_parent,model_test_testing_utilities_onchange_parent,,1,0,0,0
access_test_testing_utilities_onchange_line,access_test_testing_utilities_onchange_line,model_test_testing_utilities_onchange_line,,1,0,0,0
+access_model_test_testing_utilities_onchange_count,access_model_test_testing_utilities_onchange_count,model_test_testing_utilities_onchange_count,,1,1,1,1
+access_test_testing_utilities_onchange_count_sub,access_test_testing_utilities_onchange_count_sub,model_test_testing_utilities_onchange_count_sub,,1,1,1,1
diff --git a/odoo/addons/test_testing_utilities/menu.xml b/odoo/addons/test_testing_utilities/menu.xml
index 3ca18ef2a50..fa5f4f37163 100644
--- a/odoo/addons/test_testing_utilities/menu.xml
+++ b/odoo/addons/test_testing_utilities/menu.xml
@@ -1,4 +1,6 @@
<odoo>
+ <menuitem id="menu_ssf" parent="base.menu_tests" name="Server-side form"/>
+
<record id="action_test_o2m_onchange" model="ir.actions.act_window">
<field name="name">Test o2m onchange thing</field>
<field name="type">ir.actions.act_window</field>
@@ -6,7 +8,7 @@
<field name="view_mode">tree,form</field>
</record>
- <menuitem id="menu_o2m" parent="base.menu_tests"
+ <menuitem id="menu_o2m" parent="menu_ssf"
name="O2M onchange test thing"
action="action_test_o2m_onchange"/>
@@ -17,10 +19,21 @@
<field name="view_mode">tree,form</field>
</record>
- <menuitem id="menu_m2o" parent="base.menu_tests"
+ <menuitem id="menu_m2o" parent="menu_ssf"
name="test_default_and_onchange"
action="action_test_m2o_onchange"/>
+ <record id="action_test_o2m_count" model="ir.actions.act_window">
+ <field name="name">test_change_number_of_lines</field>
+ <field name="type">ir.actions.act_window</field>
+ <field name="res_model">test_testing_utilities.onchange_count</field>
+ <field name="view_mode">tree,form</field>
+ </record>
+
+ <menuitem id="menu_o2m_count" parent="menu_ssf"
+ name="test_change_number_of_lines"
+ action="action_test_o2m_count"/>
+
<record id="o2m_parent" model="ir.ui.view">
<field name="name">Parent External Form</field>
<field name="priority">1</field>
diff --git a/odoo/addons/test_testing_utilities/models.py b/odoo/addons/test_testing_utilities/models.py
index 324b8d62c9f..fbdec47a7cf 100644
--- a/odoo/addons/test_testing_utilities/models.py
+++ b/odoo/addons/test_testing_utilities/models.py
@@ -211,3 +211,25 @@ class M2OOnchangeLine(models.Model):
@api.onchange('dummy')
def _onchange_flag(self):
self.flag = True
+
+class O2MChangeCount(models.Model):
+ _name = 'test_testing_utilities.onchange_count'
+ _description = _name
+
+ count = fields.Integer()
+ line_ids = fields.One2many('test_testing_utilities.onchange_count_sub', 'parent')
+
+ @api.onchange('count')
+ def _onchange_count(self):
+ Sub = self.env['test_testing_utilities.onchange_count_sub']
+ recs = Sub
+ for i in range(self.count):
+ recs |= Sub.new({'name': str(i)})
+ self.line_ids = recs
+
+class O2MChangeSub(models.Model):
+ _name = 'test_testing_utilities.onchange_count_sub'
+ _description = _name
+
+ parent = fields.Many2one('test_testing_utilities.onchange_count')
+ name = fields.Char()
diff --git a/odoo/addons/test_testing_utilities/tests/test_form_impl.py b/odoo/addons/test_testing_utilities/tests/test_form_impl.py
index 344ae62f4d2..c13dbd73a60 100644
--- a/odoo/addons/test_testing_utilities/tests/test_form_impl.py
+++ b/odoo/addons/test_testing_utilities/tests/test_form_impl.py
@@ -432,6 +432,40 @@ class TestO2M(TransactionCase):
with f.line_ids.edit(index=0) as new_line:
self.assertTrue(new_line.flag)
+ def test_o2m_remove(self):
+ def commands():
+ return [c[0] for c in f._values['line_ids']]
+ f = Form(self.env['test_testing_utilities.onchange_count'])
+
+ self.assertEqual(f.count, 0)
+ self.assertEqual(len(f.line_ids), 0)
+
+ f.count = 5
+ self.assertEqual(f.count, 5)
+ self.assertEqual(len(f.line_ids), 5)
+
+ f.count = 2
+ self.assertEqual(f.count, 2)
+ self.assertEqual(len(f.line_ids), 2)
+
+ f.count = 4
+
+ r = f.save()
+
+ previous = r.line_ids
+ self.assertEqual(len(previous), 4)
+
+ with Form(r) as f:
+ f.count = 2
+ self.assertEqual(commands(), [0, 0, 2, 2, 2, 2], "Should contain 2 creations and 4 deletions")
+ self.assertEqual(len(r.line_ids), 2)
+
+ with Form(r) as f:
+ f.line_ids.remove(0)
+ self.assertEqual(commands(), [2, 1])
+ f.count = 1
+ self.assertEqual(commands(), [0, 2, 2], "should contain 1 '0' command and 2 deletions")
+ self.assertEqual(len(r.line_ids), 1)
class TestEdition(TransactionCase):
""" These use the context manager form as we don't need the record
diff --git a/odoo/fields.py b/odoo/fields.py
index e7086bd74e8..542e37aee6b 100644
--- a/odoo/fields.py
+++ b/odoo/fields.py
@@ -161,6 +161,7 @@ class Field(MetaField('DummyField', (object,), {})):
:param compute_sudo: whether the field should be recomputed as superuser
to bypass access rights (boolean, by default ``False``)
+ Note that this has no effects on non-stored computed fields
The methods given for ``compute``, ``inverse`` and ``search`` are model
methods. Their signature is shown in the following example::
diff --git a/odoo/osv/expression.py b/odoo/osv/expression.py
index eeed85ca1e8..b6638a5b3c8 100644
--- a/odoo/osv/expression.py
+++ b/odoo/osv/expression.py
@@ -262,7 +262,7 @@ def combine(operator, unit, zero, domains):
result += normalize_domain(domain)
count += 1
result = [operator] * (count - 1) + result
- return result
+ return result or unit
def AND(domains):
diff --git a/odoo/tests/common.py b/odoo/tests/common.py
index f703ca30a41..f5b04f7e307 100644
--- a/odoo/tests/common.py
+++ b/odoo/tests/common.py
@@ -1430,12 +1430,12 @@ class Form(object):
return []
v = []
- c = {t[1]: t[2] for t in current if t[0] == 1} if current else {}
+ c = {t[1]: t[2] for t in current if t[0] in (1, 2)} if current else {}
# which view should this be???
subfields = descr['views']['edition']['fields']
for command in value:
- # TODO: get existing sub-values so we can pass them along?
if command[0] in (0, 1):
+ c.pop(command[1], None) # remove record from currents
v.append((command[0], command[1], {
k: self._cleanup_onchange(
subfields[k], v, None
@@ -1443,10 +1443,14 @@ class Form(object):
for k, v in command[2].items()
if k in subfields
}))
+ elif command[0] == 2:
+ v.append((2, command[1], False))
elif command[0] == 4:
- v.append((1, command[1], c.get(command[1], {})))
+ v.append((1, command[1], c.pop(command[1], {})))
elif command[0] == 5:
v = []
+ # explicitly mark all non-relinked (or modified) records as deleted
+ for id_ in c: v.append((2, id_, False))
return v
elif descr['type'] == 'many2many':
# onchange result is a bunch of commands, normalize to single 6