diff --git a/addons_extensions/knowledge/__init__.py b/addons_extensions/knowledge/__init__.py new file mode 100644 index 000000000..666302936 --- /dev/null +++ b/addons_extensions/knowledge/__init__.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from lxml import etree + +from . import controllers +from . import models +from . import wizard + +from odoo.exceptions import UserError + + +def pre_init_knowledge(env): + """ Some lxml arm64 versions cannot decode icons and cause the installation to crash. + This will test to decode an emoji before the installation of the app, and show + a helper message if it crashed. + """ + try: + etree.fromstring("
😀
") + except etree.XMLSyntaxError: + raise UserError( + "The version of the lxml package used is not supported. " + "Consider reinstalling lxml package using 'pip install --nobinary :all: lxml'") + + +def _uninstall_knowledge(env): + env.cr.execute(""" + DROP TEXT SEARCH CONFIGURATION IF EXISTS knowledge_config CASCADE; + """) + env.cr.execute(""" + DROP TEXT SEARCH DICTIONARY IF EXISTS knowledge_dictionary; + """) + + +def _init_private_article_per_user(env): + env['res.users'].search([('partner_share', '=', False)])._generate_tutorial_articles() diff --git a/addons_extensions/knowledge/__manifest__.py b/addons_extensions/knowledge/__manifest__.py new file mode 100644 index 000000000..317278b2f --- /dev/null +++ b/addons_extensions/knowledge/__manifest__.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Knowledge', + 'summary': 'Centralize, manage, share and grow your knowledge library', + 'description': 'Centralize, manage, share and grow your knowledge library', + 'category': 'Productivity/Knowledge', + 'version': '1.0', + 'depends': [ + 'web', + 'web_editor', # still needed for backend history functions + 'digest', + 'html_editor', + 'mail', + 'portal', + 'web_unsplash', + 'web_hierarchy', + ], + 'data': [ + 'data/article_templates.xml', + 'data/digest_data.xml', + 'data/ir_config_parameter_data.xml', + 'data/ir_attachment_data.xml', + 'data/knowledge_cover_data.xml', + 'data/knowledge_article_template_category_data.xml', + 'data/knowledge_article_template_data.xml', + 'data/knowledge_article_stage_data.xml', + 'data/ir_actions_data.xml', + 'data/mail_templates.xml', + 'data/mail_templates_email_layouts.xml', + 'wizard/knowledge_invite_views.xml', + 'views/knowledge_article_views.xml', + 'views/knowledge_article_favorite_views.xml', + 'views/knowledge_article_member_views.xml', + 'views/knowledge_article_stage_views.xml', + 'views/knowledge_article_template_category_views.xml', + 'views/knowledge_templates_portal.xml', + 'views/knowledge_menus.xml', + 'views/portal_templates.xml', + 'security/ir.model.access.csv', + 'security/ir_rule.xml', + ], + 'installable': True, + 'application': True, + 'license': 'OEEL-1', + 'pre_init_hook': 'pre_init_knowledge', + 'post_init_hook': '_init_private_article_per_user', + 'uninstall_hook': '_uninstall_knowledge', + 'assets': { + 'web.assets_backend': [ + 'knowledge/static/src/scss/knowledge_common.scss', + 'knowledge/static/src/scss/knowledge_views.scss', + 'knowledge/static/src/scss/knowledge_editor.scss', + ('after', 'web/static/src/views/form/form_controller.xml', 'knowledge/static/src/xml/form_controller.xml'), + 'knowledge/static/src/xml/**/*', + 'knowledge/static/src/components/**/*', + 'knowledge/static/src/editor/**/*', + 'knowledge/static/src/comments/**/*', + 'knowledge/static/src/mail/**/*', + 'knowledge/static/src/search_model/**/*', + ('after', 'web/static/src/views/form/form_controller.js', 'knowledge/static/src/web/form_controller_patch.js'), + 'knowledge/static/src/web/**/*', + 'knowledge/static/src/js/knowledge_controller.js', + 'knowledge/static/src/js/knowledge_utils.js', + 'knowledge/static/src/js/knowledge_renderers.js', + 'knowledge/static/src/js/knowledge_views.js', + 'knowledge/static/src/webclient/**/*', + 'knowledge/static/src/views/**/*', + ('remove', 'knowledge/static/src/views/hierarchy/**'), + 'knowledge/static/src/services/**/*', + 'knowledge/static/src/macros/**/*', + ], + 'web.assets_backend_lazy': [ + 'knowledge/static/src/views/hierarchy/**', + ], + 'web.assets_backend_lazy_dark': [ + 'knowledge/static/src/scss/knowledge_views.dark.scss', + ], + "web.assets_web_dark": [ + 'knowledge/static/src/scss/knowledge_views.dark.scss', + ], + 'web.assets_frontend': [ + 'knowledge/static/src/scss/knowledge_common.scss', + 'knowledge/static/src/js/knowledge_utils.js', + ], + 'web.assets_unit_tests': [ + 'knowledge/static/tests/**/*', + ('remove', 'knowledge/static/tests/legacy/**/*'), + ('remove', 'knowledge/static/tests/tours/**/*'), + ], + 'web.assets_tests': [ + 'knowledge/static/tests/tours/**/*', + ], + # 'web.qunit_suite_tests': [ + # # 'knowledge/static/tests/legacy/**/*', # TODO: conversion + # ], + 'web.tests_assets': [ + 'knowledge/static/tests/legacy/mock_services.js', + ], + 'knowledge.webclient': [ + ('include', 'web.assets_backend'), + # knowledge webclient overrides + 'knowledge/static/src/portal_webclient/**/*', + 'web/static/src/start.js', + ], + 'web.assets_web_print': [ + 'knowledge/static/src/scss/knowledge_print.scss', + ] + }, +} diff --git a/addons_extensions/knowledge/controllers/__init__.py b/addons_extensions/knowledge/controllers/__init__.py new file mode 100644 index 000000000..978d386a0 --- /dev/null +++ b/addons_extensions/knowledge/controllers/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import article_thread +from . import main +from . import knowledge_unsplash +from . import portal diff --git a/addons_extensions/knowledge/controllers/article_thread.py b/addons_extensions/knowledge/controllers/article_thread.py new file mode 100644 index 000000000..3ae24d7bf --- /dev/null +++ b/addons_extensions/knowledge/controllers/article_thread.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import http +from odoo.addons.portal.controllers.mail import MailController +from odoo.addons.knowledge.controllers.main import KnowledgeController +from odoo.http import request +from odoo.addons.mail.controllers.thread import ThreadController +from odoo.addons.mail.tools.discuss import Store +from werkzeug.exceptions import Forbidden + + +class ArticleThreadController(KnowledgeController): + + @http.route('/knowledge/thread/create', type='json', auth='user') + def create_thread(self, article_id, article_anchor_text="", fields=["id", "article_anchor_text"]): + article_thread = request.env['knowledge.article.thread'].create({ + 'article_id': article_id, + 'article_anchor_text': article_anchor_text, + }) + return {field: article_thread[field] for field in fields} + + @http.route('/knowledge/thread/resolve', type='http', auth='user') + def resolve_thread(self, res_id, token): + _, thread, redirect = MailController._check_token_and_record_or_redirect('knowledge.article.thread', int(res_id), token) + if not thread or not thread.article_id.user_can_write: + return redirect + if not thread.is_resolved: + thread.is_resolved = True + return self.redirect_to_article(thread.article_id.id, show_resolved_threads=True) + + +class KnowledgeThreadController(ThreadController): + + @http.route("/knowledge/threads/messages", methods=["POST"], type="json", auth="user") + def mail_threads_messages(self, thread_model, thread_ids, limit=30): + thread_ids = [int(thread_id) for thread_id in thread_ids] + output = {} + for thread_id in thread_ids: + domain = self._prepare_thread_messages_domain(thread_model, thread_id) + # TODO ABD optimize duration. Currently very slow because of mail.message._to_store + res = request.env["mail.message"]._message_fetch(domain, limit=limit) + messages = res.pop("messages") + output[thread_id] = { + **res, + "data": Store(messages, for_current_user=True).get_result(), + "messages": Store.many_ids(messages), + } + return output + + def _prepare_thread_messages_domain(self, thread_model, thread_id): + return [ + ("res_id", "=", int(thread_id)), + ("model", "=", thread_model), + ("message_type", "=", "comment"), # only user input + ("subtype_id", "=", request.env.ref('mail.mt_comment').id), # comments in threads are sent as notes + ("is_internal", "=", False) # respect internal users only flag + ] diff --git a/addons_extensions/knowledge/controllers/knowledge_unsplash.py b/addons_extensions/knowledge/controllers/knowledge_unsplash.py new file mode 100644 index 000000000..878bcd875 --- /dev/null +++ b/addons_extensions/knowledge/controllers/knowledge_unsplash.py @@ -0,0 +1,62 @@ +import requests +import werkzeug + +from odoo.addons.web_unsplash.controllers import main +from odoo import http +from odoo.http import request +from werkzeug.urls import url_encode + +# ID of the unsplash collection, used as a fallback for knowledge covers when we can't find a suitable image +UNSPLASH_COLLECTION_ID = 317099 + +class KnowledgeUnsplash(main.Web_Unsplash): + + @http.route('/knowledge/article/+ + This is where you and your team can centralize your + Knowledge + and best practices! 🚀 + +
+
+ This private page is for you to play around with.
+
+ Ready to give it a spin?
+
+ Try the following 👇 +
++ Got it? Now let's try advanced features 🧠 +
+
+ Hello there, I am a template 👋
+
+ Use the buttons at the top-right of this box to re-use my content.
+
+ No more time wasted! 🔥
+
+
+ Want to go
+ even
+ faster? ⚡️
+
+
+
+ Access
+
+ Articles
+
+ by opening the
+
+ Command Palette
+
+ (Ctrl+k/⌘+k) then search through articles by starting your query with
+ "?".
+
+
+ + 👈 See the + + Menu + + there, on the left? + +
++ + Those are your + Articles. + +
++ Each of them can be used both as: +
++ + To change the way + + Articles + + are organized, you can simply + + Drag & Drop + + them + +
++ + + Articles + + are stored into different + Sections: + +
++ + And again, to move an + + Article + + from a + + Section + + to another, just + + Drag & Drop + + it. + +
++ + A good workflow is to write your drafts in + + Private + + and, once done, move it from + + Private + + to + + Workspace + + to share it with everyone. + +
++ And voilà, it is that simple. +
++ 🚀 +
++ To be sure to stay updated, follow us on + X, + + + YouTube + + + or + Facebook. +
+ + +Tip: A Knowledge well kept
+Did you know that access rights can be defined per user on any Knowledge Article?
+
+Tip: Use Clipboards to easily inject repetitive content
+Use the /clipboard command on a Knowledge Article and get going.
+
+Tip: Be on the same page
+Start working together on any Knowledge Article by sharing your article with others.
+
+Please pick the following tasks first:
+ +Otherwise, feel free to handle others listed below:
+ ++ + Productivity is never an accident. It is always the result of a commitment to excellence, intelligent planning, and focused effort. + + - Paul J. Meyer ++
Things to handle this week*
+
+
|
+
+ + *💡 tick the box when the task is scheduled in the agenda + +
++ + + Reminders + + +
++ + + MONDAY 🏢 @office + + +
+⚡ TOP 3 PRIORITIES
+Optional Tasks
+Extra Notes:
++ + + TUESDAY 🏢 @office + + +
+⚡ TOP 3 PRIORITIES
+Optional Tasks
+Extra Notes:
++ + + WEDNESDAY 🏠 @home + + +
+⚡ TOP 3 PRIORITIES
+Optional Tasks
+Extra Notes:
++ + + THURSDAY 🏢 @office + + +
+⚡ TOP 3 PRIORITIES
+Optional Tasks
+Extra Notes:
++ + + FRIDAY 🏠 @home + + +
+⚡ TOP 3 PRIORITIES
+Optional Tasks
+Extra Notes:
++ + + SATURDAY 🏠 @home + + +
+⚡ TOP 3 PRIORITIES
+Optional Tasks
+Extra Notes:
++ + + SUNDAY 🏠 @home + + +
+⚡ TOP 3 PRIORITIES
+Optional Tasks
+Extra Notes:
++ + List here all the text you could not do this week. These shall be postponed in the next weekly schedule. + +
+Compiled below are tips & tricks collected among our veterans to help newcomers get started. We hope it will help you sign deals.
++ PRO TIP: From a lead, use the book button in the chatter to find this article and autocomplete your email with this template. +
+Hey ProspectName,
+
+ I was doing some research online and found your company.
+ Considering we just launched ProductName, I was thinking you would be interested.
+
+ Could you please let me know on which number I could reach you so that we could get in touch?
+ It should not take longer than 15 minutes.
+
+ Talk to you soon,
+ YourName
+
+ PRO TIP: From a lead, use the book button in the chatter to find this article and autocomplete your description with this qualification template. +
+
+ You may have heard those a million times and yet you are not entirely sure of what it means.
+ Here is a quick recap for you to shine during the next meeting.
+
| MRR | ++ Monthly + Recurring + Revenues + (subscriptions, ...) + | +
| NRR | ++ Non-Recurring Revenues + (consultancy services, ...) + | +
| USP | ++ Unique Selling Proposition: + Advantage that makes you stand out from the competition. + | +
| Q1,2,3,4 | +
+ Nth Quarter of the fiscal year. + E.g. Q4 starts on Oct. 1 and ends on Dec. 31. + |
+
☝🏼 Please prepare the following before the meeting:
+
+ 🗣 Meeting Agenda+ |
+
+ 🙌🏼 Decisions Taken+ |
+
+
|
+
+
|
+
+
|
+
+
|
+
+
|
+
+
|
+
☝🏼 Please prepare the following before the meeting:
+
+ 🗣 Meeting Agenda+ |
+
+ 🙌🏼 Decisions Taken+ |
+
+
|
+
+
|
+
+
|
+
+
|
+
+
|
+
+
|
+
+
|
+
+
|
+
+ + Planned Next Step: + ++
+
+
+ Fast Facts+ |
+ |
| Company Name: | +|
| Industry: | +|
| Company Location: | +|
| # Employees: | +|
| Company Structure: | +|
| Estimated Revenues: | +|
+ Sales Details+ |
+ |
| Current Contract: | +|
| Contract Due Date: | +|
| Current Satisfaction: | +|
+ Objectives with our Collaboration+ |
+ |
| Issues they are trying to solve: | +|
| How they measure success: | +|
| Potential Risks: | +
+ + + Blue: Expects accurate and rigorous results + + +++ + Green: Values trust above all
+
+ Red: Values results above all
+ + + Yellow: Values creativity and enthusiasm above results + + + +
Main Point of Contact:
+| + + Name + + | ++ + Role + + | ++ + Color + + | +
| Name 1 | +Job Title 1 | +Blue/Green/Red/Yellow | +
| Name 2 | +Job Title 2 | +Blue/Green/Red/Yellow | +
| + + Name + + | ++ + Strengths + + | ++ + Weaknesses + + | +
| Competitor 1 | +||
| Competitor 2 | +||
| Competitor 3 | +
+ What is your action plan to move forward with this account?
+ Which KPI will be used to measure this progress?
+
Please submit a request for assistance to the Marketing Team if you fall into one of the following:
+For all other categories, we simply require you to follow the rules listed below.
+|
+
+ Make sure to comply as you will represent our brand
+
+ + If in doubt, get in touch with us. + |
+
+ Here are logos that you can use at your own convenience.
+
+ They can also be shared with customers, journalists and resellers.
+
+ YourCompany is very proud of its brand image.
+
+ When representing the brand, we thus ask you to be very cautious in how you refer to the company.
+
+ + + Do + ++
|
+
+ + Do Not ++
|
+
Use this template whenever you have an announcement to make.
+
+ 250 Executive Park Blvd, Suite 3400 94134
+
+ San Francisco California (US)
+
+ United States
+
+ info@yourcompany.com
+
YourCompany is proud to announce that...
+
+ About YourCompany: YourCompany is a team of passionate people whose goal is to improve everyone's life
+ through disruptive products.
+ We build great products to solve your business problems.
+
+ Our products are designed for small to medium size companies willing to optimize their performance.
+
+ + + Describe your campaign in just a few words. + + +
++ + + What are you trying to accomplish with this campaign? + + +
++ + + Who are you trying to reach? + + +
++ + + What are you trying to convince them of? + + +
++ + + How will your measure progress? + + +
+Instead of setting up complicated processes, we prefer to let our employees buy whatever they need. Just fill in an expense and we will reimburse you.
+
+ The Accounting team handles all payments on Fridays afternoon.
+
+ If 2 weeks have passed and you are still waiting to be paid, get in touch with them.
+
Our catalog can be found here and is updated every 2 years. If you do not manage to find the specific model you are looking for, contact our FleetOfficer
+ ++ Your car can be driven by you, your spouse and by any person living under the same roof as long as they have a valid permit. +
+Please refer to the chart below.
+| An hour or two (medical appointment, ...) | +Let your Team Leader know in advance. | +
| A day or less | +Inform HR and your Team Leader. | +
| Absent for more than a day | +See a doctor and send us the sick note. | +
+ + Explain what went wrong in just a few words. + +
++ + Explain the consequences of this issue. + +
++ + How did it get to happen? + +
++ + How was it solved? + +
+Lessons Learnt:
+Actions Taken:
++ Summary: What an exciting release! This time the focus was on... +
++ + Explain in a single sentence what has been changed. + +
++ + List here all the material and documentation related to the task. + +
+ + + ++ + Explain in a few words the problem that this change is going to solve. + +
++ + List here all the changes to implement. + +
++ + Ask a Senior Engineer to fill this part before launching the task. + +
+| + Change + | ++ Complexity + | +
| Change 1 | ++ + | +
| Change 2 | ++ + | +
| Change 3 | ++ + | +
Extra Technical Instructions:+
+ + Lay here any remaining question or doubt. + +
+Q1: Would it be possible to...+
Q2: Would there be an issue if...+
+ + Ask the developer to go through this checklist before asking for a review. + +
+Here is a short guide that will help you pick the right tiny house for you.
++ + 1. How many people will live in this house? + ++
While tiny houses are inherently small, it's important to prioritize comfort when sharing such limited space. To ensure everyone's happiness, we recommend allowing at least 9m² per person. If you plan to live with multiple roommates, our Serenità model is highly recommended, providing ample space for each individual to enjoy
+ + 2. What is your budget? + ++
While tiny houses do offer a more cost-effective alternative to traditional houses, at MyCompany, we prioritize durability, which comes with a price. However, if you're on a tight budget, we have the perfect solution: our Cielo model.
It provides everything you need while allowing you to save.
+ + 3. Which style do you prefer: Natural or Industrial? + ++
Whether your heart leans towards the rustic charm of a wooden hut or the intricate beauty of Victorian steelworks, we have you covered. Desiring a specific option? Rest assured, we are here to fulfill your wishes! Just get in touch with our architects and we will make sure to provide any specific choice you desire.
+Natural Style ☘ -🏘️ House Model - Incanto
+Industrial ⚙-🛕 House Model - Dolcezza
++ + 4. What can I do if I haven't found exactly what I wanted?" + ++
If none of those offers convinced you, just get in touch with our team.
At MyCompany, your happiness is our utmost priority, and we'll go the extra mile to make sure you find what you're looking for!
+ + To add more, use the clipboard below 👇🏼 + +
++ + How to best reach this customer type. + +
+
+
+ How to best reach this customer type.
+
+ As an avid music listener, the best way to reach Sonya is through the radio since hers is always on.
+
+
+ How to best reach this customer type.
+
+ Julius follows politics very tightly, and can best be reached with TV ads.
+
+
+ How to best reach this customer type.
+
+ Abigail works a lot and never watches TV. Better reach her on her phone or on her to work.
+
+
+ How to best reach this customer type.
+
+ As a classic Gen Z member, Vittorio never watches TV and never listens to the radio. For him to see our message, we need to get to his Instagram feed.
+
+ Welcome to the last edition of the MyCompany Newsletter!
+ We are very excited to share with you the hottest updates and news! 🤩
+
The podcast launches its new technical talks, for all tech-savvy listeners out there! This + new episode of the series features Géry, the mastermind behind OWL, the world fastest JS + framework. 🚀
+A must listen for all developers out there and anyone interested in Javascript!
+ 👨💻
+
+ Subscribe here to make sure you will not miss an episode!
+
PLANET ftp
+
+


This year again, ftp was present at the Tech and Innovation festival for students in
+ Antwerp, ready to promote our company!
Fabien was also there and gave an interview on the main
+ stage.📢
More than 150 ftpers participated in this years' edition of the run of 5 or 11
+ kilometers.
Starting from the office, they enjoyed a great tour in the countryside before
+ coming back to Grand-Rosière, where they were welcomed with a drink and a burger.🍔
Thank you to everyone involved in the organization of this edition of the + + ftp Run + !
+We already cannot wait for the next one🏃
+
+ And that's all for this month, folks!
+ Thanks for reading, see you soon.👋
+
+
|
+
+
+
+
+
+
+ |
| + + |
| + + | +
| + + | +
%s
") % message_body) + return changes, tracking_value_ids + + def _send_invite_mail(self, partners, permission, message=None): + self.ensure_one() + + partner_to_bodies = {} + for partner in partners: + partner_to_bodies[partner] = self.env['ir.qweb'].with_context(lang=partner.lang)._render( + 'knowledge.knowledge_article_mail_invite', + { + 'record': self, + 'user': self.env.user, + 'permission': permission, + 'message': message, + } + ) + + if self.display_name: + subject = _('Article shared with you: %s', self.display_name) + else: + subject = _('Invitation to access an article') + + if permission == 'read': + permission_label = _('Read') + else: + permission_label = _('Write') + + for partner, body in partner_to_bodies.items(): + self.with_context(lang=partner.lang).message_notify( + body=body, + email_layout_xmlid='mail.mail_notification_layout', + partner_ids=partner.ids, + subject=subject, + subtitles=[self.display_name, _('Your Access: %s', permission_label)], + ) + + def _notify_get_recipients_groups(self, message, model_description, msg_vals=None): + groups = super()._notify_get_recipients_groups(message, model_description, msg_vals=msg_vals) + if not self or not msg_vals.get('partner_ids'): + return groups + new_group = [] + for member in self.article_member_ids.filtered( + lambda member: member.partner_id.id in msg_vals['partner_ids'] and member.partner_id.partner_share + ): + url = url_join( + self.get_base_url(), + f"/knowledge/article/invite/{member.id}/{member._get_invitation_hash()}" + ) + new_group.append( + (f'group_knowledge_member_{member.id}', lambda pdata: pdata['id'] == member.partner_id.id, { + 'has_button_access': True, + 'button_access': { + 'url': url, + }, + }) + ) + return new_group + groups + + def _send_trash_notifications(self): + """ This method searches all the partners that should be notified about + articles have been trashed. As each partner to notify may have different + accessible articles depending on their rights, for each partner, we need + to retrieve the first accessible article that will be considered for + them as the root trashed article. A notification is sent to each partner + to notify with the list of their own accessible articles.""" + partners_to_notify = self.article_member_ids.filtered( + lambda member: member.permission in ['read', 'write'] + ).partner_id + + KnowledgeArticle = self.env["knowledge.article"].with_context(active_test=False, allowed_company_ids=[]) + sent_messages = self.env['mail.message'] + for partner in partners_to_notify.filtered(lambda p: not p.partner_share): + # if only one article, all the partner_to_notify have access to the article. + if len(self) == 1: + main_articles, children = self, KnowledgeArticle + else: + # Current partner may have no access to some of the articles_to_notify. + # Get all accessible articles for the current partner + partner_user = partner.user_ids.filtered(lambda u: not u.share)[0] + accessible_articles = KnowledgeArticle.with_user(partner_user).search( + [('id', 'in', self.ids)] + ) + + # "Main articles" are articles that: + # - has no parent + # - OR the current partner as no access to their parent + # - OR the parent article can be accessed but is not archived. + main_articles = accessible_articles.sudo().filtered( + lambda a: a.parent_id not in accessible_articles + ) + children = accessible_articles - main_articles + + # Set the partner lang in context to send mail in partner lang. + partner_lang = get_lang(self.env, lang_code=partner.lang).code + self = self.with_context(lang=partner_lang) # force translation of subject + + if len(main_articles) == 1: + subject = _("%s has been sent to Trash", main_articles.name or _("Untitled")) + else: + subject = _("Some articles have been sent to Trash") + + body = self.env['ir.qweb'].with_context(lang=partner_lang)._render( + 'knowledge.knowledge_article_trash_notification', { + 'articles': main_articles, + 'recipient': partner, + 'child_articles': children, + }) + + # If multiple "main articles", to keep sending only one mail, + # don't link the notification to any document. + document_to_notify = main_articles if len(main_articles) == 1 else self.env['mail.thread'] + sent_messages += document_to_notify.with_context(lang=partner_lang).message_notify( + body=body, + email_layout_xmlid='mail.mail_notification_light', + partner_ids=partner.ids, + subject=subject, + ) + + return sent_messages + + # ------------------------------------------------------------ + # BUSINESS METHODS + # ------------------------------------------------------------ + + def create_article_from_template(self): + self.ensure_one() + article = self.env["knowledge.article"].article_create(is_private=True) + article.apply_template(self.id, skip_body_update=False) + return article.id + + def apply_template(self, template_id, skip_body_update=False): + """Applies the given template on the current article + :param int template_id: Template id + :param boolean skip_body_update: Whether the method should skip writing + the body and return it for further management by the caller. Note that + this does to apply to child articles as they are not managed the same + way and are side records. Typically + - False: when creating a template based article from scratch; + - True: in other cases to avoid collaborative issues (write on + body should be done at client side); + :return str: body of the article, used notably client side for + collaborative mode + """ + self.ensure_one() + template = self.env['knowledge.article'].browse(template_id) + template.ensure_one() + + # The following algorithm will proceed in 3 steps: + # 1. In the first step, we will recursively create the articles and the + # stages following the same structure as the templates. This will + # ensure that the records exist in the database for the following steps. + # 2. In the second step, we will build a dict mapping the template + # xml ids with the article ids created from it. The dict will be + # used to convert the template xml ids mentioned in the templates + # with the ids of the articles generated from them. + # 3. In the third step, we will populate the articles using the values + # set on the associated templates. + + # Step 1: Create the articles and the stages + + template_to_article_pairs = [] + stack = [(template, self)] + + while stack: + (parent_template, parent_article) = stack.pop() + template_to_article_pairs.append((parent_template, parent_article)) + + # Create the stages: + parent_template_stages = self.env['knowledge.article.stage'].search([ + ('parent_id', '=', parent_template.id) + ]) + parent_article_stages = self.env['knowledge.article.stage'].create([{ + 'name': stage.name, + 'sequence': stage.sequence, + 'fold': stage.fold, + 'parent_id': parent_article.id + } for stage in parent_template_stages]) + + # Create the child articles: + child_templates = parent_template.child_ids.sorted( + lambda template: (template.write_date, template.id)) + if not child_templates: + continue + + child_articles_values = [] + for template in child_templates: + article_values = { + 'is_article_item': template.is_article_item, + 'parent_id': parent_article.id, + } + article_stage = next((article_stage for (article_stage, template_stage) in \ + zip(parent_article_stages, parent_template_stages) \ + if template_stage == template.stage_id), False) + if article_stage: + article_values['stage_id'] = article_stage.id + child_articles_values.append(article_values) + + child_articles = self.env['knowledge.article'].create(child_articles_values) + stack.extend(zip(child_templates, child_articles)) + + # Step 2: Build the dict mapping the template xml ids with the article ids + + template_xml_id_to_article_id_mapping = {} + all_ir_model_data = self.env['ir.model.data'].sudo().search([ + ('model', '=', 'knowledge.article'), + ('res_id', 'in', [template.id for (template, _) in template_to_article_pairs]) + ]) + + for (template, article) in template_to_article_pairs: + ir_model_data = all_ir_model_data.filtered( + lambda ir_model_data: ir_model_data.res_id == template.id) + + if ir_model_data: + template_xml_id = 'knowledge.' + ir_model_data.name + template_xml_id_to_article_id_mapping[template_xml_id] = article.id + + # When rendering the template, the `ref` function should return the id + # of the article created from the template having the given xml id. + # This will ensure that the ids stored in the body of the newly created + # article will refer to the right article and not to the original template. + + def ref(xml_id): + return template_xml_id_to_article_id_mapping[xml_id] \ + if xml_id in template_xml_id_to_article_id_mapping \ + else self.env.ref(xml_id).id + + # Step 3: Copy the template values to the new articles + + (root_template, root_article) = template_to_article_pairs.pop(0) + for (template, article) in reversed(template_to_article_pairs): + article.write({ + 'article_properties': template.article_properties or {}, + 'article_properties_definition': template.article_properties_definition, + 'body': template._render_template(ref), + 'cover_image_id': template.cover_image_id.id, + 'full_width': template.full_width, + 'icon': template.icon, + 'name': template.template_name, + }) + + values = { + 'article_properties': root_template.article_properties or {}, + 'article_properties_definition': root_template.article_properties_definition, + 'cover_image_id': root_template.cover_image_id.id, + 'full_width': root_template.full_width, + 'icon': root_template.icon, + 'name': root_article.name or root_template.template_name, + } + body = root_template._render_template(ref) + if not skip_body_update: + values['body'] = body + root_article.write(values) + + return body + + def _render_template(self, ref=False): + """ + Generates the HTML body based on the template content. + :param callable ref: The `ref` function will be used to refer to an + external record and integrate advanced elements such as embedded views + of article items and article links. + """ + self.ensure_one() + if not self.is_template or not self.template_body: + return False + + if not ref: + def ref(xml_id): + return self.env.ref(xml_id).id + + def transform_xmlid_to_res_id(match): + return str(ref(match.group('xml_id'))) + + fragment = html.fragment_fromstring(self.template_body, create_parent='div') + for element in fragment.xpath('//*[@data-embedded="view"]'): + # When encoding the "embedded props", we find and replace the function + # calls of `ref` with the ids returned by the given `ref` function for + # the given xml ids. The generated HTML will then only contain ids. + # Example: + # When the "embedded props" contains `ref('knowledge.article_template_1')`, + # we replace that string occurrence with the id returned by the given + # `ref` function evaluated with the xml_id 'knowledge.article_template_1'. + embedded_props = ast.literal_eval(re.sub( + r'(?\w+\.\w+)\'\)', + transform_xmlid_to_res_id, + element.get('data-embedded-props'))) + element.set('data-embedded-props', json.dumps(embedded_props)) + for element in fragment.xpath('//*[contains(@class, "o_knowledge_article_link")]'): + article_id = ast.literal_eval(re.sub( + r'(?\w+\.\w+)\'\)', + transform_xmlid_to_res_id, + element.get('data-res_id'))) + element.set('href', '/knowledge/article/%s' % (article_id)) + element.set('data-res_id', '%s' % (article_id)) + + return ''.join(html.tostring(child, encoding='unicode', method='html') \ + for child in fragment.getchildren()) # unwrap the elements from the parent node + + def create_default_item_stages(self): + """ Need to create stages if this article has no stage yet. """ + stage_count = self.env['knowledge.article.stage'].search_count( + [('parent_id', '=', self.id)]) + if not stage_count: + self.env['knowledge.article.stage'].create([{ + "name": stage_name, + "sequence": sequence, + "parent_id": self.id, + "fold": fold + } for stage_name, sequence, fold in [ + (_("New"), 0, False), (_("Ongoing"), 1, False), (_("Done"), 2, True)] + ]) + + # ------------------------------------------------------------ + # TOOLS + # ------------------------------------------------------------ + + @api.model + def _extract_icon_from_name(self, name): + """ See name_create / _search_display_name overrides for details. """ + if not isinstance(name, str) or len(name) < 3: + return name, None + + # we consider that a non-alphabetical and non-special character is an emoji + emoji_match = re.match(r'([^\w.,;:_%+!\\/@$€#&()*=~-]) (.*)', name) + if not emoji_match or len(emoji_match.groups()) != 2: + return name, None + + emoji = emoji_match.groups(1)[0] + article_name = emoji_match.groups(1)[1] + return article_name, emoji + + def _get_ancestor_ids(self): + """ Return the union of sets including the ids for the ancestors of + records in recordset. E.g., + * if self = Article `8` which has for parent `4` that has itself + parent `2`, return `{2, 4}`; + * if article `11` is a child of `6` and is also in `self`, return + `{2, 4, 6}`; + + :rtype: OrderedSet + """ + ancestor_ids = OrderedSet() + for article in self: + if article.id in ancestor_ids: + continue + for ancestor_id in map(int, article.parent_path.split('/')[-3::-1]): + if ancestor_id in ancestor_ids: + break + ancestor_ids.add(ancestor_id) + return ancestor_ids + + def _get_invite_url(self, partner): + self.ensure_one() + member = self.env['knowledge.article.member'].search([('article_id', '=', self.id), ('partner_id', '=', partner.id)]) + return url_join(self.get_base_url(), "/knowledge/article/invite/%s/%s" % (member.id, member._get_invitation_hash())) + + def _get_first_accessible_article(self): + """ Returns the first accessible article for the current user. + If user has favorites, return first favorite article. """ + article = self.env['knowledge.article'] + if not self.env.user._is_public(): + article = self.env['knowledge.article.favorite'].search([ + ('user_id', '=', self.env.uid), ('is_article_active', '=', True) + ], limit=1).article_id + if not article: + # retrieve workspace articles first, then private/shared ones. + article = self.search( + expression.AND([ + [('parent_id', '=', False), ('is_template', '=', False)], + self._get_read_domain(), + [('is_article_visible', '=', True)] + ]), + limit=1, + order='sequence, internal_permission desc' + ) + return article + + def get_valid_parent_options(self, search_term=""): + """ Returns the list of articles that can be set as parent for the + current article (to avoid recursions) """ + return self.search_read( + domain=[ + '&', '&', '&', '&', '&', + ('is_template', '=', False), + ('name', 'ilike', search_term), + ('id', 'not in', self.ids), + '!', ('parent_id', 'child_of', self.ids), + ('user_has_access', '=', True), + ('is_article_item', '=', False), + ], + fields=['id', 'display_name', 'root_article_id'], + limit=15, + ) + + def _get_descendants(self): + """ Returns the descendants recordset of the current article. """ + return self.env['knowledge.article'].search([('id', 'not in', self.ids), ('parent_id', 'child_of', self.ids)]) + + @api.model + def get_empty_list_help(self, help_message): + # Meant to target knowledge_article_action_trashed action only. + # -> Use the specific context key of that action to target it. + if not "search_default_filter_trashed" in self.env.context: + return super().get_empty_list_help(help_message) + get_param = self.env['ir.config_parameter'].sudo().get_param + limit_days = get_param('knowledge.knowledge_article_trash_limit_days') + try: + limit_days = int(limit_days) + except ValueError: + limit_days = self.DEFAULT_ARTICLE_TRASH_LIMIT_DAYS + title = _("No Article in Trash") + description = Markup( + _("""Deleted articles are stored in Trash an extra %(threshold)s days + before being permanently removed for your database""")) % {"threshold": limit_days} + + return super().get_empty_list_help( + f'{title}
{description}
' + ) + + def get_visible_articles(self, root_articles_ids, unfolded_ids): + """ Get the articles that are visible in the sidebar with the given + root articles and unfolded ids. + + An article is visible if it is a root article, or if it is a child + article (not item) of an unfolded visible article. + """ + if root_articles_ids: + visible_articles_domain = [ + '|', + ('id', 'in', root_articles_ids), + '&', + '&', + ('parent_id', 'in', unfolded_ids), + ('id', 'child_of', root_articles_ids), # Don't fetch hidden unfolded + ('is_article_item', '=', False) + ] + + return self.env['knowledge.article'].search( + visible_articles_domain, + order='sequence, id', + ) + return self.env['knowledge.article'] + + def _get_accessible_root_ancestors(self): + accessible_root_ancestor = self + def update_has_access(parent): + return parent.has_access('read') + accessible_root_ancestors = accessible_root_ancestor if update_has_access(accessible_root_ancestor) else self.env['knowledge.article'] + while update_has_access(accessible_root_ancestor) and update_has_access(accessible_root_ancestor.parent_id) and accessible_root_ancestor.parent_id.id: + accessible_root_ancestor = accessible_root_ancestor.parent_id + accessible_root_ancestors |= accessible_root_ancestor + return accessible_root_ancestors + + def get_sidebar_articles(self, unfolded_ids=False): + """ Get the data used by the sidebar on load in the form view. + It returns some information from every article that is accessible by + the user and that is either: + - a visible root article + - a favorite article or a favorite item (for the current user) + - the current article (except if it is a descendant of a hidden + root article or of an non accessible article - but even if it is + a hidden root article) + - an ancestor of the current article, if the current article is + shown + - a child article of any unfolded article that is shown + """ + + root_articles_domain = [ + ("parent_id", "=", False), + ("is_template", "=", False), + ("is_article_visible", "=", True) + ] + has_root_access = self.root_article_id.has_access('read') + + # Fetch root article_ids as sudo, ACLs will be checked on next global call fetching 'all_visible_articles' + # this helps avoiding 2 queries done for ACLs (and redundant with the global fetch) + root_articles_ids = self.env['knowledge.article'].sudo().search(root_articles_domain).ids + + active_article_accessible_ancestors = False + if self and not has_root_access and not self.id in root_articles_ids: + active_article_accessible_ancestors = self._get_accessible_root_ancestors() + root_articles_ids += [active_article_accessible_ancestors[-1].id] + unfolded_ids += active_article_accessible_ancestors.ids + + favorite_articles_ids = self.env['knowledge.article.favorite'].sudo().search( + [("user_id", "=", self.env.user.id), ('is_article_active', '=', True)] + ).article_id.filtered(lambda article: article.user_has_access).ids + + # Add favorite articles and items (they are root articles in the + # favorite tree) + root_articles_ids += favorite_articles_ids + + if unfolded_ids is False: + unfolded_ids = [] + + # Add active article and its parents in list of unfolded articles + if self.is_article_visible: + if self.parent_id: + unfolded_ids += self._get_ancestor_ids() + # If the current article is a hidden root article, show the article + elif not self.parent_id and self.id: + root_articles_ids += [self.id] + + all_visible_articles = self.get_visible_articles(root_articles_ids, unfolded_ids) + + return { + "articles": all_visible_articles.read( + ['name', 'icon', 'parent_id', 'category', 'is_locked', 'user_can_write', 'is_user_favorite', 'is_article_item', 'has_article_children'], + None, # To not fetch the name of parent_id + ), + "favorite_ids": favorite_articles_ids, + "active_article_accessible_root_id": active_article_accessible_ancestors[-1].id if active_article_accessible_ancestors else False + } + + def get_article_hierarchy(self, exclude_article_ids=False): + """ Return the `display_name` and `user_has_access` values of the articles that are in the + hierarchy (parent_path) of the given article from the furthest ancestor to the closest one, + excluding the ones provided in exclude_article_ids. + Requires a sudo to get the values of articles that are not accessible by the user (as the + display name of the root and parent articles are shown even if the user does not have + access to them, we consider it safe to show it for the entire hierarchy) + """ + self.ensure_one() + ancestor_ids = self._get_ancestor_ids() + if exclude_article_ids: + ancestor_ids.difference_update(exclude_article_ids) + return self.sudo().browse(reversed(list(ancestor_ids))).read(["display_name", "user_has_access"]) diff --git a/addons_extensions/knowledge/models/knowledge_article_favorite.py b/addons_extensions/knowledge/models/knowledge_article_favorite.py new file mode 100644 index 000000000..071ad6f5f --- /dev/null +++ b/addons_extensions/knowledge/models/knowledge_article_favorite.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, exceptions, fields, models, _ + + +class ArticleFavorite(models.Model): + _name = 'knowledge.article.favorite' + _description = 'Favorite Article' + _order = 'sequence ASC, id DESC' + _rec_name = 'article_id' + + article_id = fields.Many2one( + 'knowledge.article', 'Article', + index=True, required=True, ondelete='cascade') + user_id = fields.Many2one( + 'res.users', 'User', + index=True, required=True, ondelete='cascade') + is_article_active = fields.Boolean('Is Article Active', related='article_id.active', + store=True, readonly=True) + sequence = fields.Integer(default=0) + + _sql_constraints = [ + ('unique_article_user', + 'unique(article_id, user_id)', + 'User already has this article in favorites.') + ] + + @api.model_create_multi + def create(self, vals_list): + """ At creation, we need to set the max sequence, if not given, for each favorite to create, in order to keep + a correct ordering as much as possible. Some sequence could be given in create values, that could lead to + duplicated sequence per user_id. That is not an issue as they will be resequenced the next time the user reorder + their favorites. """ + # TDE TODO: env.uid -> user_id + default_sequence = 1 + if any(not vals.get('sequence') for vals in vals_list): + favorite = self.env['knowledge.article.favorite'].search( + [('user_id', '=', self.env.uid)], + order='sequence DESC', + limit=1 + ) + default_sequence = favorite.sequence + 1 if favorite else default_sequence + for vals in vals_list: + if not vals.get('sequence'): + vals['sequence'] = default_sequence + default_sequence += 1 + return super(ArticleFavorite, self).create(vals_list) + + def write(self, vals): + """ Whatever rights, avoid any attempt at privilege escalation. """ + if ('article_id' in vals or 'user_id' in vals) and not self.env.is_admin(): + raise exceptions.AccessError(_("Can not update the article or user of a favorite.")) + return super().write(vals) + + def resequence_favorites(self, article_ids): + # Some article may not be accessible by the user anymore. Therefore, + # to prevent an access error, one will only resequence the favorites + # related to the articles accessible by the user + sequence = 0 + # Keep the same order as in article_ids + for article_id in article_ids: + self.search([('article_id', '=', article_id), ('user_id', '=', self.env.uid)]).write({"sequence": sequence}) + sequence += 1 diff --git a/addons_extensions/knowledge/models/knowledge_article_member.py b/addons_extensions/knowledge/models/knowledge_article_member.py new file mode 100644 index 000000000..4e7385ba8 --- /dev/null +++ b/addons_extensions/knowledge/models/knowledge_article_member.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, tools, _ +from odoo.exceptions import AccessError, ValidationError + + +class ArticleMember(models.Model): + _name = 'knowledge.article.member' + _description = 'Article Member' + _rec_name = 'partner_id' + + article_id = fields.Many2one( + 'knowledge.article', 'Article', + ondelete='cascade', required=True) + partner_id = fields.Many2one( + 'res.partner', 'Partner', + index=True, ondelete='cascade', required=True) + permission = fields.Selection( + [('write', 'Can edit'), + ('read', 'Can read'), + ('none', 'No access')], + required=True, default='read') + article_permission = fields.Selection( + related='article_id.inherited_permission', + readonly=True, store=True) + + _sql_constraints = [ + ('unique_article_partner', + 'unique(article_id, partner_id)', + 'You already added this partner on this article.') + ] + + @api.constrains('article_permission', 'permission') + def _check_is_writable(self, on_unlink=False): + """ Articles must always have at least one writer. This constraint is done + on member level, in coordination to the constraint on article model (see + ``_check_is_writable`` on ``knowledge.article``). + + Since this constraint only triggers if we have at least one member another + validation is done on article model. The article_permission related field + has been added and stored to force triggering this constraint when + article.permission is modified. + + Note: computation is done in Py instead of using optimized SQL queries + because value are not yet in DB at this point. + + :param bool on_unlink: when called on unlink we must remove the members + in self (the ones that will be deleted) to check if one of the remaining + members has write access. + """ + if self.env.context.get('knowledge_member_skip_writable_check'): + return + + articles_to_check = self.article_id.filtered(lambda a: a.inherited_permission != 'write') + if not articles_to_check: + return + + if on_unlink: + deleted_members_by_article = dict.fromkeys(articles_to_check.ids, self.env['knowledge.article.member']) + for member in self.filtered(lambda member: member.article_id in articles_to_check): + deleted_members_by_article[member.article_id.id] |= member + + for article in articles_to_check: + # Check on permission on members + members_to_check = article.article_member_ids + if on_unlink: + members_to_check -= deleted_members_by_article[article.id] + if any(m.permission == 'write' for m in members_to_check): + continue + + members_to_exclude = deleted_members_by_article[article.id] if on_unlink else False + if not article._has_write_member(members_to_exclude=members_to_exclude): + raise ValidationError( + _("Article '%s' should always have a writer: inherit write permission, or have a member with write access", + article.display_name) + ) + + def write(self, vals): + """ Whatever rights, avoid any attempt at privilege escalation. """ + if ('article_id' in vals or 'partner_id' in vals) and not self.env.is_admin(): + raise AccessError(_("Can not update the article or partner of a member.")) + return super().write(vals) + + @api.ondelete(at_uninstall=False) + def _unlink_except_no_writer(self): + """ When removing a member, the constraint is not triggered. + We need to check manually on article with no write permission that we do not remove the last write member """ + self._check_is_writable(on_unlink=True) + + def _get_invitation_hash(self): + """ We use a method instead of a field in order to reduce DB space.""" + self.ensure_one() + return tools.hmac(self.env(su=True), + 'knowledge-article-invite', + f'{self.id}-{self.create_date}-{self.partner_id.id}-{self.article_id.id}' + ) diff --git a/addons_extensions/knowledge/models/knowledge_article_stage.py b/addons_extensions/knowledge/models/knowledge_article_stage.py new file mode 100644 index 000000000..affbdae19 --- /dev/null +++ b/addons_extensions/knowledge/models/knowledge_article_stage.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class KnowledgeStage(models.Model): + _name = "knowledge.article.stage" + _description = "Knowledge Stage" + _order = 'parent_id, sequence, id' + + name = fields.Char(required=True, translate=True) + sequence = fields.Integer(default=1) + fold = fields.Boolean("Folded in kanban view") + parent_id = fields.Many2one("knowledge.article", string="Owner Article", + required=True, ondelete="cascade", help="Stages are shared among a" + "common parent and its children articles." + ) diff --git a/addons_extensions/knowledge/models/knowledge_article_template_category.py b/addons_extensions/knowledge/models/knowledge_article_template_category.py new file mode 100644 index 000000000..731133884 --- /dev/null +++ b/addons_extensions/knowledge/models/knowledge_article_template_category.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class ArticleTemplateCategory(models.Model): + """This model represents the categories of the article templates.""" + _name = "knowledge.article.template.category" + _description = "Article Template Category" + _order = "sequence ASC, id ASC" + + name = fields.Char(string="Title", translate=True, required=True) + sequence = fields.Integer("Category Sequence", default=0, required=True, + help="It determines the display order of the category") diff --git a/addons_extensions/knowledge/models/knowledge_article_thread.py b/addons_extensions/knowledge/models/knowledge_article_thread.py new file mode 100644 index 000000000..2212ae0e3 --- /dev/null +++ b/addons_extensions/knowledge/models/knowledge_article_thread.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.tools import html2plaintext + + +class KnowledgeArticleThread(models.Model): + """ + This is the model for a comment thread linked to a `knowledge.article`. Each thread inherits + the `mail.thread` mixin. + + These threads allow end-users to discuss specific parts of the body of a knowledge article. + Which enables reviews, taking notes, pinging a colleague to get more information on a topic, ... + + Each initial comment starts its own thread, which will then accumulate replies, reactions, etc. + It is also possible to mark a thread as closed so that it no longer appears inside the editor + of the article if the conversation does not need to be continued. + """ + _name = "knowledge.article.thread" + _description = "Article Discussion Thread" + _inherit = ['mail.thread'] + _mail_post_access = 'read' # if you can read, you can post a message on an article thread + _order = 'write_date desc, id desc' + _rec_name = 'display_name' + + _ANCHOR_TEXT_MAX_LENGTH = 1200 + + article_anchor_text = fields.Text("Anchor Text", + help="The original highlighted anchor text, giving initial context if that text is modified or removed afterwards." + ) + article_id = fields.Many2one('knowledge.article', ondelete="cascade", readonly=True, required=True) + is_resolved = fields.Boolean("Thread Closed", tracking=True) + + @api.depends('article_id') + def _compute_display_name(self): + for record in self: + record.display_name = record.article_id.display_name + + +# =========================================================================== +# CRUD METHODS +# =========================================================================== + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if 'article_anchor_text' in vals: + article_anchor_text = html2plaintext(vals['article_anchor_text']) + vals['article_anchor_text'] = (article_anchor_text[:self._ANCHOR_TEXT_MAX_LENGTH] + '...') \ + if len(article_anchor_text) > self._ANCHOR_TEXT_MAX_LENGTH else article_anchor_text + + return super(KnowledgeArticleThread, self.with_context(mail_create_nolog=True)).create(vals_list) + + def write(self, vals): + if 'is_resolved' in vals: + self.ensure_one() + if 'article_anchor_text' in vals: + article_anchor_text = html2plaintext(vals['article_anchor_text']) + vals['article_anchor_text'] = (article_anchor_text[:self._ANCHOR_TEXT_MAX_LENGTH] + '...') \ + if len(article_anchor_text) > self._ANCHOR_TEXT_MAX_LENGTH else article_anchor_text + return super().write(vals) + + +# ========================================================================== +# THREAD OVERRIDES +# ========================================================================== + + def message_post(self, **kwargs): + """This function overrides the 'mail.thread' message_post in order to control what portal + users that have access to an article can post through a thread message. + + Before posting as a portal we filter what's being sent to lessen security risks. Notably + partner_ids should be a list of ids (not the records themselves) so that we don't allow command + executions. + """ + self.ensure_one() + if self.env.user._is_portal() and self.article_id.user_has_access: + authorized_keys = {'body', 'partner_ids', 'author_id', 'attachment_ids'} + return super().message_post( + **{key: kwargs.get(key) for key in authorized_keys}, + message_type='comment', subtype_xmlid='mail.mt_comment' + ) + kwargs.update({'message_type': 'comment', 'subtype_xmlid': 'mail.mt_comment'}) + return super().message_post(**kwargs) + + def _get_access_action(self, access_uid=None, force_website=False): + self.ensure_one() + user = self.env['res.users'].sudo().browse(access_uid) if access_uid else self.env.user + action = { + 'type': 'ir.actions.act_url', + 'url': f'/knowledge/article/{self.article_id.id}', + } + if access_uid is None: + action['target_type'] = 'public' + if self.article_id.with_user(user).user_has_access or access_uid is None: + return action + return super()._get_access_action(access_uid=access_uid, force_website=force_website) + + def _notify_thread_by_email(self, message, recipients_data, **kwargs): + """We need to override this method to set our own mail template to be sent to users that + have been tagged inside a comment. We are using the template 'knowledge.knowledge_mail_notification_layout' + which is a simple template comprised of the comment sent and the person that tagged the notified user. + """ + if not kwargs.get('msg_vals', {}).get('partner_ids', []): + return + kwargs['msg_vals'] = {**kwargs.get('msg_vals', {}), 'email_layout_xmlid': 'knowledge.knowledge_mail_notification_layout'} + + return super()._notify_thread_by_email(message, recipients_data, **kwargs) + + def _message_compute_subject(self): + self.ensure_one() + return _('New Mention in %s') % self.display_name + + def _notify_get_recipients(self, message, msg_vals, **kwargs): + recipients_data = super()._notify_get_recipients(message, msg_vals, **kwargs) + recipients_data = [data for data in recipients_data if data['id'] in msg_vals.get('partner_ids', [])] + + return recipients_data + + def _notify_get_recipients_groups(self, message, model_description, msg_vals=None): + groups = super()._notify_get_recipients_groups( + message, model_description, msg_vals=msg_vals + ) + if message.model != 'knowledge.article.thread': + return groups + + self.ensure_one() + action = self._notify_get_action_link('controller', controller='/knowledge/thread/resolve', **msg_vals) + user_actions = [{'url': action, 'title': _('Mark Comment as Closed')}] + + new_groups = [( + 'group_knowledge_article_thread_portal_and_users', + lambda pdata: + pdata['uid'] and self.article_id.with_user(pdata['uid']).user_has_access, + { + 'actions': user_actions, + 'active': True, + 'has_button_access': True, + } + )] + + return new_groups + groups diff --git a/addons_extensions/knowledge/models/knowledge_cover.py b/addons_extensions/knowledge/models/knowledge_cover.py new file mode 100644 index 000000000..4944c259b --- /dev/null +++ b/addons_extensions/knowledge/models/knowledge_cover.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError + + +class Cover(models.Model): + _name = "knowledge.cover" + _description = "Knowledge Cover" + + attachment_id = fields.Many2one("ir.attachment", string="Cover attachment", required=True, ondelete="cascade") + article_ids = fields.One2many("knowledge.article", "cover_image_id", string="Articles using cover") + attachment_url = fields.Char("Cover URL", compute="_compute_attachment_url", store=True) + + @api.depends('attachment_id') + def _compute_attachment_url(self): + # Add an url for frontend access. + for cover in self: + if cover.attachment_id.url: + cover.attachment_url = cover.attachment_id.url + else: + access_token = cover.attachment_id.generate_access_token()[0] + cover.attachment_url = "/web/image/%s?access_token=%s" % (cover.attachment_id.id, access_token) + + @api.model_create_multi + def create(self, vals_list): + """ Create the covers, then link the attachments used to the created + records, because when uploading a new cover, the attachment is uploaded + with res_id=0, then the cover is created using the uploaded attachment. + """ + if any(len(vals) == 1 and 'name' in vals for vals in vals_list): + raise UserError(_('You cannot create a new Knowledge Cover from here.')) + covers = super().create(vals_list) + + for cover in covers.filtered(lambda cover: not cover.attachment_id.res_id): + cover.attachment_id.write({'res_model': 'knowledge.cover', 'res_id': cover.id, }) + + return covers + + @api.autovacuum + def _gc_unused_covers(self): + return self.with_context(active_test=False).search([('article_ids', '=', False)]).unlink() diff --git a/addons_extensions/knowledge/models/res_partner.py b/addons_extensions/knowledge/models/res_partner.py new file mode 100644 index 000000000..a2142e9c4 --- /dev/null +++ b/addons_extensions/knowledge/models/res_partner.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + + +class Partner(models.Model): + _name = 'res.partner' + _inherit = 'res.partner' + + def unlink(self): + """ This override will delete all the private articles linked to the deleted partners. """ + self.env['knowledge.article.member'].sudo().search( + [('partner_id', 'in', self.ids), ('article_id.category', '=', 'private')] + ).article_id.unlink() + return super(Partner, self).unlink() diff --git a/addons_extensions/knowledge/models/res_users.py b/addons_extensions/knowledge/models/res_users.py new file mode 100644 index 000000000..261b2ad8c --- /dev/null +++ b/addons_extensions/knowledge/models/res_users.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import _, models, api + + +class Users(models.Model): + _name = 'res.users' + _inherit = 'res.users' + @api.model_create_multi + def create(self, vals_list): + users = super(Users, self).create(vals_list) + if not self.env.context.get('knowledge_skip_onboarding_article'): + users.filtered(lambda user: not user.partner_share)._generate_tutorial_articles() + return users + + def _generate_tutorial_articles(self): + articles_to_create = [] + for user in self: + self = self.with_context(lang=user.lang or self.env.user.lang) + render_ctx = {'object': user} + body = self.env['ir.qweb']._render( + 'knowledge.knowledge_article_user_onboarding', + render_ctx, + minimal_qcontext=True, + raise_if_not_found=False + ) + if not body: + break + + articles_to_create.append({ + 'article_member_ids': [(0, 0, { + 'partner_id': user.partner_id.id, + 'permission': 'write', + })], + 'body': body, + 'icon': "👋", + 'internal_permission': 'none', + 'is_article_visible_by_everyone': False, + 'favorite_ids': [(0, 0, { + 'sequence': 0, + 'user_id': user.id, + })], + 'name': _('Welcome %s', user.name), + }) + + if articles_to_create: + self.env['knowledge.article'].sudo().create(articles_to_create) diff --git a/addons_extensions/knowledge/security/ir.model.access.csv b/addons_extensions/knowledge/security/ir.model.access.csv new file mode 100644 index 000000000..2e1fa07f6 --- /dev/null +++ b/addons_extensions/knowledge/security/ir.model.access.csv @@ -0,0 +1,30 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_knowledge_article_all,access.knowledge.article.all,knowledge.model_knowledge_article,,0,0,0,0 +access_knowledge_article_portal,access.knowledge.article.portal,knowledge.model_knowledge_article,base.group_portal,1,1,1,0 +access_knowledge_article_user,access.knowledge.article.user,knowledge.model_knowledge_article,base.group_user,1,1,1,0 +access_knowledge_article_system,access.knowledge.article.system,knowledge.model_knowledge_article,base.group_system,1,1,1,1 +access_knowledge_article_thread_all,access.knowledge.article.thread.all,knowledge.model_knowledge_article_thread,,0,0,0,0 +access_knowledge_article_thread_portal,access.knowledge.article.thread.portal,knowledge.model_knowledge_article_thread,base.group_portal,1,1,1,0 +access_knowledge_article_thread_user,access.knowledge.article.thread.user,knowledge.model_knowledge_article_thread,base.group_user,1,1,1,0 +access_knowledge_article_thread_system,access.knowledge.article.thread.system,knowledge.model_knowledge_article_thread,base.group_system,1,1,1,1 +access_knowledge_article_member_all,access.knowledge.article.member.all,knowledge.model_knowledge_article_member,,0,0,0,0 +access_knowledge_article_member_portal,access.knowledge.article.member.portal,knowledge.model_knowledge_article_member,base.group_portal,1,0,0,0 +access_knowledge_article_member_user,access.knowledge.article.member.user,knowledge.model_knowledge_article_member,base.group_user,1,0,0,0 +access_knowledge_article_member_system,access.knowledge.article.member.system,knowledge.model_knowledge_article_member,base.group_system,1,1,1,1 +access_knowledge_article_favorite_all,access.knowledge.article.favorite.all,knowledge.model_knowledge_article_favorite,,0,0,0,0 +access_knowledge_article_favorite_portal,access.knowledge.article.favorite.portal,knowledge.model_knowledge_article_favorite,base.group_portal,1,1,1,1 +access_knowledge_article_favorite_user,access.knowledge.article.favorite.user,knowledge.model_knowledge_article_favorite,base.group_user,1,1,1,1 +access_knowledge_article_favorite_system,access.knowledge.article.favorite.system,knowledge.model_knowledge_article_favorite,base.group_system,1,1,1,1 +access_knowledge_article_stage_all,access.knowledge.article.stage.all,knowledge.model_knowledge_article_stage,,0,0,0,0 +access_knowledge_article_stage_portal,access.knowledge.article.stage.portal,knowledge.model_knowledge_article_stage,base.group_portal,1,1,1,1 +access_knowledge_article_stage_user,access.knowledge.article.stage.user,knowledge.model_knowledge_article_stage,base.group_user,1,1,1,1 +access_knowledge_article_stage_system,access.knowledge.article.stage.system,knowledge.model_knowledge_article_stage,base.group_system,1,1,1,1 +access_knowledge_article_template_category_all,access.knowledge.article.template.category.all,knowledge.model_knowledge_article_template_category,,0,0,0,0 +access_knowledge_article_template_category_system,access.knowledge.article.template.category.system,knowledge.model_knowledge_article_template_category,base.group_system,1,1,1,1 +access_knowledge_article_template_category_user,access.knowledge.article.template.category.user,knowledge.model_knowledge_article_template_category,base.group_user,1,0,0,0 +access_knowledge_cover_all,access.knowledge.cover.all,knowledge.model_knowledge_cover,,0,0,0,0 +access_knowledge_cover_user,access.knowledge.cover.user,knowledge.model_knowledge_cover,base.group_user,1,1,1,1 +access_knowledge_cover_system,access.knowledge.cover.system,knowledge.model_knowledge_cover,base.group_system,1,1,1,1 +access_knowledge_invite_all,access.knowledge.invite.all,knowledge.model_knowledge_invite,,0,0,0,0 +access_knowledge_invite_user,access.knowledge.invite.user,knowledge.model_knowledge_invite,base.group_user,1,1,1,0 +access_knowledge_invite_system,access.knowledge.invite.system,knowledge.model_knowledge_invite,base.group_system,1,1,1,1 diff --git a/addons_extensions/knowledge/security/ir_rule.xml b/addons_extensions/knowledge/security/ir_rule.xml new file mode 100644 index 000000000..83090f4a9 --- /dev/null +++ b/addons_extensions/knowledge/security/ir_rule.xml @@ -0,0 +1,121 @@ + ++ Highlight content and use the button to add comments +
+
+ Organize your database with custom fields
+ (Text, Selection, ...).
+ Those fields will be available on all articles that share the same parent.
+
Start typing to continue with an empty page or pick an option below to get started.
+ +Missing Calendar configuration.
+Hello World
+ `, + }); + const thread = pyEnv["knowledge.article.thread"].create({ + article_id: articleId, + }); + const messageThreadNotif = pyEnv["mail.message"].create({ + author_id: serverState.partnerId, + body: "Howdy Neighbor", + needaction: true, + model: "knowledge.article.thread", + res_id: thread, + }); + pyEnv["mail.notification"].create({ + mail_message_id: messageThreadNotif, + notification_status: "sent", + notification_type: "inbox", + res_partner_id: serverState.partnerId, + }); + mockService("action", { + doAction(action, params) { + expect(Boolean(params?.additionalContext?.res_id)).toBe(true); + expect(action).toBe("knowledge.ir_actions_server_knowledge_home_page"); + step("knowledge_action_called"); + }, + }); + await start(); + await click(".o-mail-DiscussSystray-class .fa-comments"); + await click(".o-mail-NotificationItem"); + await contains(".o-mail-ChatWindow"); + await click("[title='Open Actions Menu']"); + await click(".o-dropdown-item", { text: "Open Form View" }); + await assertSteps(["knowledge_action_called"]); + await contains(".o-mail-ChatWindow", { count: 0 }); +}); diff --git a/addons_extensions/knowledge/static/tests/legacy/knowledge_behaviors_upgrade_tests.js b/addons_extensions/knowledge/static/tests/legacy/knowledge_behaviors_upgrade_tests.js new file mode 100644 index 000000000..d590e1c6c --- /dev/null +++ b/addons_extensions/knowledge/static/tests/legacy/knowledge_behaviors_upgrade_tests.js @@ -0,0 +1,202 @@ +/** @odoo-module */ + +// web +import { status } from "@odoo/owl"; +import { getFixture, makeDeferred, nextTick, patchWithCleanup } from "@web/../tests/helpers/utils"; +import { makeView, setupViewRegistries } from "@web/../tests/views/helpers"; +import { getOrigin } from "@web/core/utils/urls"; + + +// web_editor +import { HtmlField } from "@web_editor/js/backend/html_field"; +import { unformat } from "@web_editor/js/editor/odoo-editor/test/utils"; +import { parseHTML } from "@web_editor/js/editor/odoo-editor/src/utils/utils"; + + +// knowledge +import { + decodeDataBehaviorProps, + getPropNameNodes, +} from "@knowledge/js/knowledge_utils"; + +//------------------------------------------------------------------------------ +// Upgrade utils +//------------------------------------------------------------------------------ + +/** + * Assert that the desired attributes are set with the correct value on an + * anchor element. + * + * @param {Object} attributes {name: value} to check for + * @param {Element} anchor + * @param {Object} assert assert object from QUnit + */ +function assertAttributes(attributes, anchor, assert) { + for (const attr in attributes) { + assert.equal(anchor.getAttribute(attr), attributes[attr], `The value of attribute: ${attr} was not as expected.`); + } +} + +/** + * Assert that the behavior props registered in `data-behavior-props` attribute + * of anchor are correct. + * + * This function also validates that every prop currently registered on the + * anchor has been tested. + * + * @param {Object} props {name: value} to check for + * @param {Element} anchor + * @param {Object} assert assert object from QUnit + */ +function assertBehaviorProps(props, anchor, assert) { + const behaviorProps = decodeDataBehaviorProps(anchor.dataset.behaviorProps); + for (const prop in props) { + assert.deepEqual(behaviorProps[prop], props[prop], `The value of prop: ${prop} in data-behavior-props was not as expected.`); + } + assert.deepEqual(new Set(Object.keys(behaviorProps)), new Set(Object.keys(props)), "data-behavior-props should only contain valid props from Behavior props schema"); +} + +//------------------------------------------------------------------------------ +// QUnit setup +//------------------------------------------------------------------------------ + +let fixture; +let serverData; +let arch; +let record; +let htmlFieldPromise; + +function beforeEach() { + htmlFieldPromise = makeDeferred(); + patchWithCleanup(HtmlField.prototype, { + async startWysiwyg() { + await super.startWysiwyg(...arguments); + await nextTick(); + htmlFieldPromise.resolve(this); + } + }); + fixture = getFixture(); + record = { + id: 1, + name: "Upgrade Article", + body: "\n
\n
note
", + memo: "memo
", + description: "description
", + comment: "comment
", + narration: "narration
", + delivery_instructions: "delivery instructions
", + product_details: "product details
", + user_feedback: "user feedback
", + }], + } + }, + }; + setupViewRegistries(); + // Remove the mock_service (which is a dummy) and replace it with + // the real KnowledgeCommandsService. + serviceRegistry.remove("knowledgeCommandsService"); + serviceRegistry.add("knowledgeCommandsService", knowledgeCommandsService); + }); + + QUnit.test("Don't validate a html field candidate from a forbidden model", async function (assert) { + assert.expect(1); + arch = ` + + `; + await makeView({ + type: "form", + resModel: "knowledge.article", + serverData, + arch, + resId: 1, + }); + formController._evaluateRecordCandidate(); + // Forbidden models are defined in KNOWLEDGE_EXCLUDED_MODELS in the + // Knowledge form_controller_patch. They typically are models which + // have a heavily customized form view so a generic macro won't be able + // to navigate them. `knowledge.article` is one of them. + assert.equal( + formController.knowledgeCommandsService.getCommandsRecordInfo(), + null + ); + }); + + QUnit.test("Validate a visible editable html field with priority", async function (assert) { + assert.expect(1); + arch = ` + + `; + await makeView({ + type: "form", + resModel: "product.product", + serverData, + arch, + resId: 1, + }); + formController._evaluateRecordCandidate(); + // Here the selected html field should be `narration`, because + // every other field declared in the xml view before it is either + // readonly (on the model or specifically in the view), + // invisible (the field itself or one of its parent nodes), + // not in the priority list defined in the Knowledge + // form_controller_patch (KNOWLEDGE_RECORDED_FIELD_NAMES). + assert.equal( + formController.knowledgeCommandsService.getCommandsRecordInfo().fieldInfo.name, + "narration", + ); + }); + + QUnit.test("Select a candidate in a named page, in order of declaration", async function (assert) { + assert.expect(1); + arch = ` + + `; + await makeView({ + type: "form", + resModel: "product.product", + serverData, + arch, + resId: 1, + }); + formController._evaluateRecordCandidate(); + // Here the selected html field should be `user_feedback`, because + // it is the first field declared in the first named page of the + // xml view. This test also demonstrates that the alphabetical order + // is not considered, since `delivery_instructions` is not chosen. + assert.equal( + formController.knowledgeCommandsService.getCommandsRecordInfo().fieldInfo.name, + "user_feedback", + ); + }); +}); + +//============================================================================== +// Save Scenarios +//============================================================================== + +QUnit.module("Knowledge - Ensure body save scenarios", (hooks) => { + hooks.beforeEach(() => { + patchWithCleanup(KnowledgeArticleFormController.prototype, { + setup() { + super.setup(...arguments); + formController = this; + } + }); + htmlFieldPromise = makeDeferred(); + patchWithCleanup(HtmlField.prototype, { + async startWysiwyg() { + await super.startWysiwyg(...arguments); + await nextTick(); + htmlFieldPromise.resolve(this); + } + }); + record = { + id: 1, + display_name: "Article", + body: "as1[]
b1
as1
b1
[]
No data to display
'), + name: embedKanbanActWindowName, + res_model: 'knowledge.article', + type: 'ir.actions.act_window', + views: [[false, 'kanban']], + view_mode: 'kanban', +}; + +const articleItemsKanbanActionContext = () => { + return { + active_id: articleId, + default_parent_id: articleId, + default_is_article_item: true, + }; +}; + +const embedKanbanActWindowSteps = [{ // manually insert view from act_window object + trigger: '.odoo-editor-editable > p', + run: function () { + const context = articleItemsKanbanActionContext(); + const selection = document.getSelection(); + selection.setBaseAndExtent(this.anchor, 0, this.anchor, 0); + plugin.insertEmbeddedView( + articleItemsKanbanAction, + articleItemsKanbanAction.name, + "kanban", + { context } + ); + }, +}, +...commonKanbanSteps(embedKanbanActWindowName)]; + +//------------------------------------------------------------------------------ +// TOUR STEPS - MISC +//------------------------------------------------------------------------------ + +/* + * MISC: Verifying view filtering mechanics. + * When you enable a filter on an embed view, it it saved and restored if you go back to that view. + * See: 'knowledgeEmbedViewsFilters' for more details + */ + +const embedViewFiltersSteps = [{ + // Check that we have 2 elements in the embedded view + trigger: 'tbody tr.o_data_row:nth-child(2)', +}, { // add a simple filter + trigger: '.o_searchview_input_container input', + run: "edit 1", +}, { + trigger: 'li[id="1"]', + run: "click", +}, { // Check that the filter is effective + trigger: 'tbody:not(tr.o_data_row:nth-child(2))', +}, { // Open the filtered article + trigger: 'tbody > tr > td[name="display_name"]', + run: "click", +}, { // Wait for the article to be open + trigger: '.o_hierarchy_article_name input:value("Child 1")', +}, { // Go back via the pager + trigger: '.o_knowledge_header i.oi-chevron-left', + run: "click", +}, { // Check that there is the filter in the searchBar + trigger: '.o_searchview_input_container', +}, { // Check that the filter is effective + trigger: 'tbody:not(tr.o_data_row:nth-child(2))', +}]; + +// MISC: Test opening an article item through the kanban view + +const embedKanbanEditArticleSteps = [{ // Create a new article using quick create in OnGoing Column + trigger: `${embedViewSelector(embedKanbanName)} .o_kanban_renderer .o_kanban_group .o_kanban_header_title:contains("Ongoing") .o_kanban_quick_add`, + run: 'click' +}, { // Type a Title for new article in the quick create form + trigger: `${embedViewSelector(embedKanbanName)} .o_kanban_renderer .o_kanban_group:has(.o_kanban_header_title:contains("Ongoing")) .o_kanban_quick_create .o_input`, + run: "edit Quick Create Ongoing Item", +}, { // Click on Edit to open the article in edition in his own form view + trigger: `${embedViewSelector(embedKanbanName)} .o_kanban_renderer .o_kanban_quick_create .o_kanban_edit`, + run: 'click' +}, { // verify that the view switched to the article item + trigger: '.o_knowledge_header .o_hierarchy_article_name input:value("Quick Create Ongoing Item")', +}, { // Go back via the pager + trigger: '.o_knowledge_header i.oi-chevron-left', + run: "click", +}, { // Wait for the article to be properly loaded + trigger: '.odoo-editor-editable:contains("EditorCommandsArticle Content")', +}]; + +/* + * MISC: Verifying /article command inside the mail composer. + * We add specific code to make the /article command work inside the composer, notably in relation + * to the "to inline" process. + * See '_toInline' knowledge override in html_field.js + */ + +const composeBody = '.modal-dialog:contains(Compose Email) [name="body"]'; +const articleCommandComposerSteps = [{ // open the chatter + trigger: '.btn-chatter', + run: "click", +}, { // open the message editor + trigger: '.o-mail-Chatter-sendMessage:not([disabled=""])', + run: "click", +}, { // open the full composer + trigger: "button[aria-label='Full composer']", + run: "click", +}, ...appendArticleLink(`${composeBody}`, 'EditorCommandsArticle'), { // wait for the block to appear in the editor + trigger: `${composeBody} .o_knowledge_article_link:contains("EditorCommandsArticle")`, +}, ...appendArticleLink(`${composeBody}`, 'LinkedArticle', `.o_knowledge_article_link:contains("EditorCommandsArticle")`), { // wait for the block to appear in the editor, after the previous one + trigger: `${composeBody} .odoo-editor-editable > p > a:nth-child(2).o_knowledge_article_link:contains("LinkedArticle")[contenteditable="false"]`, +}, { // verify that the first block is still there and contenteditable=false + trigger: `${composeBody} .odoo-editor-editable > p > a:nth-child(1).o_knowledge_article_link:contains("EditorCommandsArticle")[contenteditable="false"]`, +}, { // send the message + trigger: '.o_mail_send', + run: "click", +}, { + trigger: '.o_widget_knowledge_chatter_panel .o-mail-Thread .o-mail-Message-body > p > a:nth-child(1).o_knowledge_article_link:contains("EditorCommandsArticle")', +}, { + trigger: '.o_widget_knowledge_chatter_panel .o-mail-Thread .o-mail-Message-body > p > a:nth-child(2).o_knowledge_article_link:contains("LinkedArticle")', +}, { // close the chatter + trigger: '.btn-chatter', + run: 'click', +}]; + +// MISC: Article command usage + +const articleCommandUsageSteps = [{ // wait for the block to appear in the editor + trigger: '.o_knowledge_article_link:contains("LinkedArticle")', + run: 'click', +}, { // check that the view switched to the corresponding article + trigger: '.o_knowledge_header:has(.o_hierarchy_article_name input:value("LinkedArticle"))', + run: "click", +}, { // Go back via the pager + trigger: '.o_knowledge_header i.oi-chevron-left', + run: "click", +}, { // Wait for the article to be properly loaded + trigger: '.odoo-editor-editable:contains("EditorCommandsArticle Content")', +}]; + +/** MISC: Clipboard usage on a contact + * + * Has to stay last for 2 reasons: + * - It's important to be executed in an article that has embed views inside it, to make sure that + * the breadcrumbs from embed views don't interfere with the macro system ; + * - It actually leaves the main article, meaning any steps after this one would have to manually + * re-enter the article from the Knowledge app (could have side effects, see file introduction). + */ + +const clipboardUsageSteps = [{ // open the chatter + trigger: '.btn-chatter', + run: 'click', +}, { + trigger: '.o-mail-Thread', +}, { // open the follower list of the article + trigger: '.o-mail-Followers-button', + run: 'click', +}, { // open the contact record of the follower + trigger: '.o-mail-Follower-details:contains(HelloWorldPartner)', + run: 'click', +}, { // verify that the partner form view is fully loaded + trigger: '.o_breadcrumb .o_last_breadcrumb_item.active:contains(HelloWorldPartner)', +}, { // return to the knowledge article by going back from the breadcrumbs + trigger: '.o_breadcrumb a:contains(EditorCommandsArticle)', + run: 'click', +}, { + trigger: "[data-embedded='clipboard'] button:first:contains(Copy)", +}, { // open the chatter again + trigger: '.btn-chatter', + run: 'click', +}, { + trigger: '.o-mail-Thread', +}, { // open the follower list of the article + trigger: '.o-mail-Followers-button', + run: 'click', +}, { // open the contact record of the follower + trigger: '.o-mail-Follower-details:contains(HelloWorldPartner)', + run: 'click', +}, { // verify that the partner form view is fully loaded + trigger: '.o_breadcrumb .o_last_breadcrumb_item.active:contains(HelloWorldPartner)', +}, { // search an article to open it from the contact record + trigger: 'button[title="Search Knowledge Articles"]', + run: 'click', +}, { // open the article + trigger: '.o_command_default:contains(EditorCommandsArticle)', + run: 'click', +}, { // wait for article to be correctly loaded + trigger: '.o_hierarchy_article_name input:value("EditorCommandsArticle")', +}, { // use the template as description for the contact record + trigger: "[data-embedded='clipboard'] button:contains(Use as)", + run: 'click', +}, { // check that the content of the template was inserted as description + trigger: '.o_form_sheet .o_field_html .odoo-editor-editable p:contains("Hello world")', +}]; + +registry.category("web_tour.tours").add('knowledge_article_commands_tour', { + url: '/odoo', + checkDelay: 50, + steps: () => [stepUtils.showAppsMenuItem(), { + // open the Knowledge App + trigger: '.o_app[data-menu-xmlid="knowledge.knowledge_menu_root"]', + run: "click", +}, + // Regular commands + ...articleCommandSteps, + ...fileCommandSteps, + ...indexCommandSteps, + ...tocCommandSteps, + ...videoCommandSteps, + ...clipboardCommandSteps, + // Embed view commands + ...embeddedViewPatchSteps, + ...listCommandSteps, + ...embedKanbanSteps, + ...embedKanbanActWindowSteps, + ...embedCardsKanbanSteps, + // Misc + ...embedViewFiltersSteps, + ...embedKanbanEditArticleSteps, + ...articleCommandUsageSteps, + ...articleCommandComposerSteps, + ...clipboardUsageSteps, // has to stay last, see steps docstring + ...unpatchSteps, + ...endKnowledgeTour() +]}); diff --git a/addons_extensions/knowledge/static/tests/tours/commands/knowledge_calendar_command_tour.js b/addons_extensions/knowledge/static/tests/tours/commands/knowledge_calendar_command_tour.js new file mode 100644 index 000000000..bb413db84 --- /dev/null +++ b/addons_extensions/knowledge/static/tests/tours/commands/knowledge_calendar_command_tour.js @@ -0,0 +1,461 @@ +/** @odoo-module */ + +import { registry } from "@web/core/registry"; +import { + embeddedViewPatchFunctions, + endKnowledgeTour, + openCommandBar, +} from "../knowledge_tour_utils.js"; +import { stepUtils } from "@web_tour/tour_service/tour_utils"; + +const embeddedViewPatchUtil = embeddedViewPatchFunctions(); + +function clickDate(el) { + const rect = el.getBoundingClientRect(); + const eventParams = { + bubbles: true, + clientX: rect.left + 3, + clientY: rect.top + 3, + }; + el.dispatchEvent(new MouseEvent('mousedown', eventParams)); + el.dispatchEvent(new MouseEvent('mouseup', eventParams)); +} + +function dragDate(el, target) { + // Cannot use drag_and_drop because it uses the center of the elements + const elRect = el.getBoundingClientRect(); + el.dispatchEvent(new MouseEvent('mousedown', { + bubbles: true, + clientX: elRect.left + 1, + clientY: elRect.top + 1, + })); + const targetRect = target.getBoundingClientRect(); + target.dispatchEvent(new MouseEvent('mousemove', { + bubbles: true, + clientX: targetRect.left + 1, + clientY: targetRect.top + 1, + })); + target.dispatchEvent(new MouseEvent('mouseup', { + bubbles: true, + clientX: targetRect.left + 1, + clientY: targetRect.top + 1, + })); +} + +registry.category("web_tour.tours").add('knowledge_calendar_command_tour', { + url: '/odoo', + checkDelay: 80, + steps: () => [stepUtils.showAppsMenuItem(), { // open the Knowledge App + trigger: '.o_app[data-menu-xmlid="knowledge.knowledge_menu_root"]', + run: "click", +}, { + trigger: "body", + run: () => { + embeddedViewPatchUtil.before(); + }, +}, { + //----------------------------------------------- + // Insert a new item calendar view in the article + //----------------------------------------------- + + // Open the command bar + trigger: '.odoo-editor-editable > p', + run: function () { + openCommandBar(this.anchor); + }, +}, { // Click on the /calendar command + trigger: '.o-we-command-name:contains("Calendar")', + run: 'click', +}, { // As the article does not have properties definitions, it should create default ones + trigger: '.modal-footer .btn-primary', + run: "click", +}, { // Scroll to the embedded view to load it + trigger: '[data-embedded="view"]', + run: function() { + this.anchor.scrollIntoView(true); + } +}, +{ + trigger: + "[data-embedded='view']", +}, +{ + //--------------------------------------------------- + // Create an article item by clicking in the calendar + //--------------------------------------------------- + + // Click on a date + trigger: '.fc-timegrid-slot.fc-timegrid-slot-lane[data-time="08:00:00"]', + run: function () { + clickDate(this.anchor); + }, +}, +{ + trigger: ".o_hierarchy_article_name input:empty", +}, +{ + // Check we created an item with the right datetime used as property + trigger: '.o_knowledge_properties_field .o_property_field:contains("Start Date")', + run: function () { + const input = this.anchor.querySelector("input"); + if (!input.value.includes("08:00:00")) { + throw new Error('Item was not created with the correct property value'); + } + }, +}, { // Set the name of the item + trigger: '.o_knowledge_editor .odoo-editor-editable h1', + run: "editor Item Article", +}, { // Go back to parent article + trigger: '.o_knowledge_tree .o_article_name:contains("EditorCommandsArticle")', + run: 'click', +}, { // Check that the item is shown in the calendar + trigger: '.fc-timegrid-event .o_event_title:contains("Item Article")', +}, { + //-------------------------------------------------------------- + // Insert another item calendar view (to test advanced settings) + // and create new start and stop properties to use by the view + //-------------------------------------------------------------- + + content: "Remove previous item calendar view", + trigger: '.odoo-editor-editable', + run: "editor ", +}, +{ + trigger: ".odoo-editor-editable:not(:has( [data-embedded='view']))", +}, +{ + // Click on the "Create Item Calednar" helper + trigger: '.o_knowledge_helper .o_knowledge_add_item_calendar', + run: 'click', +}, { // Open the start date dropdown + trigger: '.o_knowledge_item_calendar_dialog_date_start .o_select_menu_toggler', + run: 'click', +}, { // Create a new start property + trigger: '.o_select_menu_menu input', + run: "edit Start Property", +}, { + trigger: '.o_select_menu_menu .o_select_menu_item.o_create_datetime', + run: 'click', +}, { // Open the stop dropwdown + trigger: '.o_knowledge_item_calendar_dialog_date_stop .o_select_menu_toggler', + run: 'click', +}, { // Create a new stop property + trigger: '.o_select_menu_menu input', + run: "edit Stop Property", +}, { + trigger: '.o_select_menu_menu .o_select_menu_item.o_create_choice', + run: 'click', +}, { // Change the min slot time + trigger: ".o_knowledge_item_calendar_dialog_slot_min_time input", + run: function () { + this.anchor.value = "08:00"; + this.anchor.dispatchEvent(new Event("change")); + }, +}, { // Change the max slot time + trigger: ".o_knowledge_item_calendar_dialog_slot_max_time input", + run: function () { + this.anchor.value = "16:30"; + this.anchor.dispatchEvent(new Event("change")); + }, +}, { // Hide Weekends + trigger: "input[type='checkbox']", + run: "click" +}, { // Insert the calendar + trigger: '.modal-footer .btn-primary', + run: 'click', +}, +{ + trigger: + "[data-embedded='view'] .o_knowledge_article_view_calendar_embedded_view", +}, { // Check that the display options are applied + trigger: ".fc-timegrid-slot:not(.fc-timegrid-slot-lane[data-time='07:00:00'])", +}, { + trigger: ".fc-timegrid-slot.fc-timegrid-slot-lane[data-time='08:00:00']", +}, { + trigger: ".fc-timegrid-slot:not(.fc-timegrid-slot-lane[data-time='16:30:00'])", +}, { + trigger: ".fc-timegrid-slot.fc-timegrid-slot-lane[data-time='16:00:00']", +}, { + trigger: ":not(.fc-day-sat), :not(.fc-day-sun)", +}, +{ + //--------------------------------------------------- + // Create an article item by clicking in the calendar + //--------------------------------------------------- + + // Click on a date + trigger: '.fc-timegrid-slot.fc-timegrid-slot-lane[data-time="08:00:00"]', + run: function () { + clickDate(this.anchor); + }, +}, +{ + trigger: ".o_hierarchy_article_name input:empty", +}, +{ + // Check we created an item with the right datetime used as property + trigger: '.o_knowledge_properties_field .o_property_field:contains("Start Property")', + run: function () { + const input = this.anchor.querySelector("input"); + if (!input.value.includes("08:00:00")) { + throw new Error('Item was not created with the correct property value'); + } + }, +}, { + //----------------------------------------------------------------------- + // Create new properties from the article view that will be used later in + // this tour + //----------------------------------------------------------------------- + + // Create a new date property + trigger: '.o_knowledge_properties_field .o_field_property_add button', + run: 'click', +}, { + trigger: '.o_field_property_definition_header', + run: "edit Date Property", +}, { + trigger: '.o_field_property_definition_type button.dropdown-toggle', + run: 'click', +}, { + trigger: '.dropdown-menu .dropdown-item:contains("Date"):not(:contains("Time"))', + run: 'click', +}, { + trigger: '.o_knowledge_editor .odoo-editor-editable', + run: 'click', +}, { // Create a new checkbox property + trigger: '.o_knowledge_properties_field .o_field_property_add button', + run: 'click', +}, { + trigger: '.o_field_property_definition_header', + run: "edit Boolean Property", +}, { + trigger: '.o_field_property_definition_type button.dropdown-toggle', + run: 'click', +}, { + trigger: '.dropdown-menu .dropdown-item:contains("Checkbox")', + run: 'click', +}, { + trigger: '.o_knowledge_editor .odoo-editor-editable', + run: 'click', +}, { // Create a text property + trigger: '.o_knowledge_properties_field .o_field_property_add button', + run: 'click', +}, { + trigger: '.o_field_property_definition_header', + run: "edit Text Property", +}, { + trigger: '.o_field_property_definition_type button.dropdown-toggle', + run: 'click', +}, { + trigger: '.dropdown-menu .dropdown-item:contains("Text")', + run: 'click', +}, { + trigger: '.o_knowledge_editor .odoo-editor-editable', + run: 'click', +}, { // Set the text property + trigger: '.o_knowledge_properties_field .o_property_field:contains("Text Property") input', + run: 'edit Custom text && click body', +}, { // Set the name of the item + trigger: '.o_knowledge_editor .odoo-editor-editable h1', + run: "editor Item Article", +}, { // Go back to parent article + trigger: '.o_knowledge_tree .o_article_name:contains("Article Items")', + run: 'click', +}, { // Check that the item is shown in the calendar + trigger: '.fc-timegrid-event .o_event_title:contains("Item Article")', +}, { + //------------------------------------------------------------------------- + // Test the props editor dialog by changing the values, check that the view + // is updated accordingly, and set the start and stop dates back to check + // that the item article is shown again + //------------------------------------------------------------------------- + + // Open the view props editor + trigger: '.o_control_panel_breadcrumbs_actions .dropdown-toggle', + run: 'click', +}, { + trigger: '.dropdown-item:contains(Edit)', + run: "click", +}, { // Change the start property + trigger: '.o_knowledge_item_calendar_dialog_date_start .o_select_menu_toggler', + run: 'click', +}, { + trigger: '.o_select_menu_menu .o_select_menu_item:contains("Date Property")', + run: 'click', +}, { // Check that stop date has been removed as the start type changed, + trigger: '.o_knowledge_item_calendar_dialog_date_stop .o_select_menu_toggler_slot span.text-muted', +}, { // Open the stop property dropdown + trigger: '.o_knowledge_item_calendar_dialog_date_stop .o_select_menu_toggler', + run: 'click', +}, { // Check that one cannot use the selected start date + trigger: '.o_knowledge_item_calendar_dialog_date_stop .o_select_menu:not(:contains("Date Property"))', +}, { // Don't select a stop property + trigger: '.o_knowledge_item_calendar_props_dialog', + run: 'click', +}, { // Open the color property dropdown + trigger: '.o_color .o_select_menu_toggler', + run: 'click', +}, { // Select the previously created property + trigger: '.o_select_menu_menu .o_select_menu_item:contains("Boolean Property")', + run: 'click', +}, { // Open the scale dropdown + trigger: '.o_scale .o_select_menu_toggler', + run: 'click', +}, { // Select the month scale + trigger: '.o_select_menu_menu .o_select_menu_item:contains("Month")', + run: 'click', +}, { // Save changes + trigger: '.modal-footer .btn-primary', + run: 'click', +}, +{ + trigger: ".fc-view:not(:has(.fc-event-container))", +}, +{ + // Check calendar has been updated (new scale and no item shown) + trigger: '.o_knowledge_article_view_calendar_embedded_view .o_calendar_header .o_view_scale_selector:contains("Month")', +}, { // Change start and stop dates again + trigger: '.o_control_panel_breadcrumbs_actions .dropdown-toggle', + run: 'click', +}, { + trigger: '.dropdown-item:contains(Edit)', + run: "click", +}, { // Change the start property + trigger: '.o_knowledge_item_calendar_dialog_date_start .o_select_menu_toggler', + run: 'click', +}, { + trigger: '.o_select_menu_menu .o_select_menu_item:contains("Start Property")', + run: 'click', +}, { // Check that stop date has been removed as the start type changed, + trigger: '.o_knowledge_item_calendar_dialog_date_stop .o_select_menu_toggler_slot span.text-muted', +}, { // Open the stop property dropdown + trigger: '.o_knowledge_item_calendar_dialog_date_stop .o_select_menu_toggler', + run: 'click', +}, { // Select the stop date + trigger: '.o_select_menu_menu .o_select_menu_item:contains("Stop Property")', + run: 'click', +}, { // Save changes + trigger: '.modal-footer .btn-primary', + run: 'click', +}, { // Open the view + trigger: '.o_control_panel_breadcrumbs_actions .dropdown-toggle', + run: 'click', +}, { + trigger: '.dropdown-item:contains(Open)', + run: "click", +}, +{ + trigger: ".o_knowledge_article_view_calendar_embedded_view.o_action", +}, +{ + // Check that the item is shown + trigger: '.fc-view .o_event_title:contains("Item Article")', +}, { // Leave the app and come back to make sure that changes have been saved + trigger: '.o_main_navbar .o_menu_toggle', + run: "click", +}, { + trigger: '.o_app[data-menu-xmlid="knowledge.knowledge_menu_root"]', + run: 'click', +}, +{ + trigger: "[data-embedded='view']", +}, +{ + //---------------------------- + // Move the item and resize it + //---------------------------- + + // Change the scale from the calendar view + trigger: '.o_knowledge_article_view_calendar_embedded_view .o_calendar_header .o_view_scale_selector button:contains("Month")', + run: 'click', +}, { + trigger: '.o-dropdown--menu .o_scale_button_week', + run: 'click', +}, { // Move the item in the calendar + trigger: '.fc-timegrid-event .o_event_title:contains("Item Article")', + run: function () { + const target = document.querySelector('.fc-timegrid-slot.fc-timegrid-slot-lane[data-time="09:00:00"]'); + dragDate(this.anchor, target); + }, +}, { // Make resizer visible + trigger: '.fc-timegrid-event', + run: function () { + const resizer = this.anchor.querySelector(".fc-event-resizer-end"); + resizer.style.display = "block"; + resizer.style.width = "100%"; + resizer.style.height = "3px"; + resizer.style.bottom = "0"; + }, +}, { + trigger: '.fc-timegrid-event:contains("Item Article") .fc-event-resizer-end', + run: function () { + const target = document.querySelector('.fc-timegrid-slot.fc-timegrid-slot-lane[data-time="11:00:00"]'); + dragDate(this.anchor, target); + }, +}, { + //---------------------------------------------------------------------- + // Check that the date properties have been updated correctly after that + // the item has been moved in the item calendar view, and that the text + // property has not been changed + //---------------------------------------------------------------------- + + // Open the item + trigger: '.fc-timegrid-event', + run: 'dblclick', +}, +{ + trigger: '.o_hierarchy_article_name input:value("Item Article")', +}, +{ + // Check that the properties have been updated + trigger: '.o_knowledge_properties_field .o_property_field:contains("Start Property")', + run: function () { + const input = this.anchor.querySelector("input"); + if (!input.value.includes("09:00:00")) { + console.error('Item start date property has not been updated'); + } + }, +}, { + trigger: '.o_knowledge_properties_field .o_property_field:contains("Stop Property")', + run: function () { + const input = this.anchor.querySelector("input"); + // When resizing an event, the event spans the hovered row, so we need to add 15 minutes + if (!input.value.includes("11:15:00")) { + console.error('Item stop date property has not been updated'); + } + }, +}, { // Check text property did not change + trigger: '.o_knowledge_properties_field .o_property_field:contains("Text Property")', + run: function () { + const input = this.anchor.querySelector("input"); + if (!input.value.includes("Custom text")) { + console.error('Item text property has changed'); + } + }, +}, { + //--------------------------------------------------------------------- + // Remove start property to test the behavior of the item calendar view + // when the required props are missing + //--------------------------------------------------------------------- + + // Click on edit property button + trigger: ".o_knowledge_properties_field .o_property_field:contains(Start Property)", + run: "hover && click .o_knowledge_properties_field .o_property_field:contains(Start Property) .o_field_property_open_popover", +}, { // Delete start date property + trigger: '.o_field_property_definition .o_field_property_definition_delete', + run: 'click', +}, { // Confirm deletion + trigger: '.modal-dialog .btn-primary', + run: 'click', +}, { // Go back to parent article + trigger: '.o_knowledge_tree .o_article_name:contains("Article Items")', + run: 'click', +}, { // Make sure view is not crashed and shows nocontent helper + trigger: '.o_knowledge_article_view_calendar_embedded_view .o_knowledge_item_calendar_nocontent', +}, { + trigger: 'body', + run: () => { + embeddedViewPatchUtil.after(); + }, +}, ...endKnowledgeTour() +]}); diff --git a/addons_extensions/knowledge/static/tests/tours/commands/knowledge_search_favorites_tour.js b/addons_extensions/knowledge/static/tests/tours/commands/knowledge_search_favorites_tour.js new file mode 100644 index 000000000..ac4c0c567 --- /dev/null +++ b/addons_extensions/knowledge/static/tests/tours/commands/knowledge_search_favorites_tour.js @@ -0,0 +1,262 @@ +/** @odoo-module */ + +import { registry } from "@web/core/registry"; +import { endKnowledgeTour, openCommandBar } from "../knowledge_tour_utils.js"; +import { stepUtils } from "@web_tour/tour_service/tour_utils"; + +/** + * Verify that a filter is not duplicated and is properly maintained after + * a round trip with the breadcrumbs. + * + * @param {String} kanban name of a kanban view in which records can be created + * @param {String} filterName name of a favorite filter which is already present in the view + * @returns {Array} steps + */ +// const validateFavoriteFilterPersistence = function(kanban, filterName) { +// return [{ +// content: 'create and edit item in the kanban view', +// trigger: `[data-embedded="view"] .o_kanban_view:contains(${kanban}) .o-kanban-button-new`, +// run: "click", +// }, { +// content: 'Give the name to the item', +// trigger: 'input#name_0', +// run: "edit Item 1", +// }, { +// content: 'click on the edit button', +// trigger: '.o_kanban_edit', +// run: "click", +// }, +// { +// trigger: '.o_hierarchy_article_name input:value("Item 1")', +// }, +// { +// content: `go to the ${kanban} from the breadcrumb`, +// trigger: '.o_knowledge_header i.oi-chevron-left', +// run: "click", +// }, { +// // Open the favorite of the first kanban and check it's favorite +// trigger: `.o_breadcrumb:contains('${kanban}')`, +// run: function () { +// const view = this.anchor.closest( +// '.o_kanban_view' +// ); +// const searchMenuButton = view.querySelector(".o_searchview_dropdown_toggler"); +// searchMenuButton.click(); +// }, +// }, { +// trigger: '.o_favorite_menu', +// run: function () { +// const favorites = this.anchor.querySelectorAll("span.dropdown-item"); +// if (favorites.length !== 1 || favorites[0].innerText !== filterName) { +// console.error(`Only one filter "(${filterName})" should be available`); +// } +// }, +// }] +// }; // TODO uncomment when OWL is ready + +/** + * Insert the Knowledge kanban view as an embedded view in article. + * + * @param {String} article article name + * @returns {Array} steps + */ +const embedKnowledgeKanbanViewSteps = function (article) { + return [{ // open the Knowledge App + trigger: ".o_app[data-menu-xmlid='knowledge.knowledge_menu_root']", + run: "click", + }, { // click on the search menu + trigger: "[role='menuitem']:contains(Search)", + run: "click", + }, { // toggle on the kanban view + trigger: ".o_switch_view.o_kanban", + run: "click", + }, { // wait for the kanban view + trigger: ".o_kanban_renderer", + }, { // open action menu dropdown + trigger: ".o_control_panel .o_cp_action_menus button", + run: "click", + }, { // click on the knowledge menu button + trigger: ".dropdown-menu .dropdown-toggle:contains(Knowledge)", + run: function () { + this.anchor.dispatchEvent(new Event("mouseenter")); + }, + }, { // click on insert view in article + trigger: ".dropdown-menu .dropdown-item:contains('Insert view in article')", + run: "click", + }, { // embed in article + trigger: `.modal-dialog td.o_field_cell:contains(${article})`, + run: "click", + }]; +}; + +/** + * Test favorite filters and use by default filters in embedded views in + * Knowledge. Need an article with 2 named kanban embeds to work. + * + * @param {String} kanban1 name of the first kanban + * @param {String} kanban2 name of the second kanban + * @returns {Array} steps + */ +// const validateFavoriteFiltersSteps = function (kanban1, kanban2) { +// return [{ +// content: 'Open the search panel menu', +// trigger: `[data-embedded="view"] .o_control_panel:contains(${kanban1}) .o_searchview_dropdown_toggler`, +// run: "click", +// }, { +// trigger: ".o_favorite_menu .o_add_favorite", +// run: "click", +// }, { +// trigger: ".o_favorite_menu:contains(Favorites) input[type='text']", +// run: "edit testFilter && click .o_favorite_menu", +// }, { +// // use by default +// trigger: ".o_favorite_menu .o-checkbox label:contains(Default filter)", +// run: "click", +// }, { +// trigger: ".o_favorite_menu .o_save_favorite", +// run: "click", +// }, +// ...stepUtils.toggleHomeMenu(), +// { +// // open the Knowledge App +// trigger: ".o_app[data-menu-xmlid='knowledge.knowledge_menu_root']", +// run: "click", +// }, { +// // check that the search item has been added +// trigger: ".o_facet_value", +// run: function () { +// const items = [...document.querySelectorAll(".o_searchview_facet")]; +// const testFacets = items.filter((el) => { +// return ( +// el.querySelector(".o_searchview_facet_label .fa-star") && +// el.querySelector(".o_facet_values")?.innerText === "testFilter" +// ); +// }); +// if (testFacets.length !== 1) { +// console.error("The 'testFilter' facet should be applied only on the first view"); +// } +// }, +// }, { +// // Open the favorite of the second kanban and check it has no favorite +// // (favorite are defined per view) +// trigger: `.o_breadcrumb:contains('${kanban2}')`, +// run: function () { +// const view = this.anchor.closest( +// '.o_kanban_view' +// ); +// const searchMenuButton = view.querySelector(".o_searchview_dropdown_toggler"); +// searchMenuButton.click(); +// }, +// }, { +// trigger: ".o_favorite_menu", +// run: function () { +// const items = document.querySelectorAll(".o_favorite_menu .dropdown-item"); +// if (items.length !== 1 || items[0].innerText !== "Save current search") { +// console.error("The favorite should not be available for the second view"); +// } +// }, +// }]; +// }; // TODO uncomment when OWL is ready + +registry.category("web_tour.tours").add("knowledge_items_search_favorites_tour", { + url: "/odoo", + steps: () => [ + stepUtils.showAppsMenuItem(), + { + // open the Knowledge App + trigger: ".o_app[data-menu-xmlid='knowledge.knowledge_menu_root']", + run: "click", + }, + { + trigger: ".o_field_html", + run: function () { + const header = document.querySelector(".o_hierarchy_article_name input"); + if (header.value !== "Article 1") { + console.error(`Wrong article: ${header.value}`); + } + }, + }, + // Create the first Kanban + { + trigger: ".odoo-editor-editable > h1", + run: function () { + openCommandBar(this.anchor); + }, + }, + { + trigger: ".o-we-command-name:contains('Item Kanban')", + run: "click", + }, + { + trigger: ".modal-body input.form-control", + run: "edit Items 1", + }, + { + trigger: "button:contains('Insert')", + run: "click", + }, + // wait for kanban 1 to be inserted + { + trigger: "[data-embedded='view'] .o_control_panel:contains(Items 1)", + }, + // Create the second Kanban + { + trigger: ".odoo-editor-editable > h1", + run: function () { + openCommandBar(this.anchor); + }, + }, + { + trigger: ".o-we-command-name:contains('Item Kanban')", + run: "click", + }, + { + trigger: ".modal-body input.form-control", + run: "edit Items 2", + }, + { + trigger: "button:contains('Insert')", + run: "click", + }, + // wait for kanban 2 to be inserted + { + trigger: "[data-embedded='view'] .o_control_panel:contains(Items 2)", + }, + // ...validateFavoriteFiltersSteps("Items 1", "Items 2"), // TODO remove comment when OWL is good + // testFilter was added as a favorite during validateFavoriteFiltersSteps to Items 1 + // ...validateFavoriteFilterPersistence("Items 1", "testFilter"), // TODO remove comment when OWL is good + ...endKnowledgeTour(), + ], +}); + +registry.category("web_tour.tours").add("knowledge_search_favorites_tour", { + url: "/odoo", + steps: () => [stepUtils.showAppsMenuItem(), + // insert a first kanban view + ...embedKnowledgeKanbanViewSteps("Article 1"), + { // wait for embedded view to load and click on rename button + trigger: + "[data-embedded='view']:has( .o_control_panel:contains(Articles)) .o_control_panel_breadcrumbs_actions .dropdown-toggle", + run: "click", + }, { + trigger: '.dropdown-item:contains(Edit)', + run: "click", + }, { // rename the view Kanban 1 + trigger: '.modal-dialog input.form-control', + run: `edit Kanban 1`, + }, { // click on rename + trigger: "button:contains('Rename')", + run: "click", + }, { // check the application of the rename + trigger: '[data-embedded="view"] .o_control_panel:contains(Kanban 1)', + }, + ...stepUtils.toggleHomeMenu(), + // insert a second kanban view + ...embedKnowledgeKanbanViewSteps("Article 1"), + { // wait for embedded view to load + trigger: '[data-embedded="view"] .o_control_panel:contains(Articles)', + }, + // ...validateFavoriteFiltersSteps("Kanban 1", "Articles"), + ...endKnowledgeTour(), + ], +}); diff --git a/addons_extensions/knowledge/static/tests/tours/knowledge_article_comments_tour.js b/addons_extensions/knowledge/static/tests/tours/knowledge_article_comments_tour.js new file mode 100644 index 000000000..e68151de2 --- /dev/null +++ b/addons_extensions/knowledge/static/tests/tours/knowledge_article_comments_tour.js @@ -0,0 +1,92 @@ +/** @odoo-module */ + +import { registry } from "@web/core/registry"; +import { insertText } from "@web/../tests/utils"; +import { stepUtils } from "@web_tour/tour_service/tour_utils"; + +import { endKnowledgeTour } from "./knowledge_tour_utils.js"; +import { setSelection, boundariesIn } from "@web_editor/js/editor/odoo-editor/src/utils/utils"; + +const addAnswerComment = (commentText) => [{ + trigger: '.o-mail-Composer-input', + run: async () => { + await insertText('.o-mail-Composer-input', commentText); + } +}, { + // Send comment + trigger: '.o-mail-Composer-send:not([disabled=""])', + run: "click", +}, { + trigger: `.o-mail-Thread :contains(${commentText})`, +}]; + +registry.category('web_tour.tours').add('knowledge_article_comments', { + url: '/odoo', + steps: () => [ + stepUtils.showAppsMenuItem(), { // Open Knowledge App + trigger: '.o_app[data-menu-xmlid="knowledge.knowledge_menu_root"]', + run: "click", + }, { + trigger: 'section[data-section="workspace"] .o_article .o_article_name:contains("Sepultura")', + run: "click", + }, { + trigger: '.o_knowledge_comment_box[data-thread-id], .o_knowledge_comment_small_ui img', + run: "click", + }, { + trigger: '.o-mail-Thread :contains("Marc, can you check this?")', + }, + ...addAnswerComment("Sure thing boss, all done!"), + { + content: "Hover on first message to make actions visible and click on it", + trigger: ".o-mail-Message-core:first", + run: "hover && click .o-mail-Message-actions:first", + }, + { + content: "Resolve Thread", + trigger: ".o-mail-Message-actions:first button[name=closeThread]", + run: "click", + }, + { + content: "Wait for the composer to be fully closed", + trigger: "body:not(:has(.o-mail-Thread))", + }, + { + content: "Select some text in the first paragraph", + trigger: ".note-editable p.o_knowledge_tour_first_paragraph", + run: function () { + setSelection(...boundariesIn(this.anchor)); + }, + }, { // Trigger comment creation with the editor toolbar + trigger: '.o-we-toolbar button[name="comments"]', + run: "click", + }, { + trigger: '.o_knowledge_comments_popover .o-mail-Composer-input', + run: async () => { + await insertText('.o-mail-Composer-input', 'My Knowledge Comment'); + } + }, { // Send comment + trigger: '.o_knowledge_comments_popover .o-mail-Composer-send:not([disabled=""])', + run: "click", + }, { // Wait for the comment to be fully created + trigger: ".note-editable p.o_knowledge_tour_first_paragraph a:not([data-id='undefined'])", + }, { + trigger: '.o_knowledge_comment_box[data-thread-id] .o_knowledge_comment_small_ui img', + }, { // Open the comments panel + trigger: '.btn-comments', + run: "click", + }, { // Panel loads un-resolved messages + trigger: '.o-mail-Thread :contains("My Knowledge Comment")', + }, { // Switch to "resolved" mode + trigger: '.o_knowledge_comments_panel select', + run: "select resolved", + }, { // Panel loads resolved messages + trigger: '.o-mail-Thread :contains("Sure thing boss, all done!")', + }, { // Open the comment to enable replies + trigger: '.o_knowledge_comment_box', + run: "click", + }, + // Add an extra reply to the resolved comment + ...addAnswerComment("Oops forgot to mention, will be done in task-112233"), + ...endKnowledgeTour() + ] +}); diff --git a/addons_extensions/knowledge/static/tests/tours/knowledge_cover_picker.js b/addons_extensions/knowledge/static/tests/tours/knowledge_cover_picker.js new file mode 100644 index 000000000..1d9133ede --- /dev/null +++ b/addons_extensions/knowledge/static/tests/tours/knowledge_cover_picker.js @@ -0,0 +1,269 @@ +/** @odoo-module */ + +import { endKnowledgeTour } from "./knowledge_tour_utils.js"; +import { registry } from "@web/core/registry"; +import { stepUtils } from "@web_tour/tour_service/tour_utils"; + +function moveCover(position) { + const cover = document.querySelector(".o_knowledge_cover img"); + cover.dispatchEvent(new PointerEvent("pointerdown")); + document.dispatchEvent(new PointerEvent("pointermove", { clientY: position })); + document.dispatchEvent(new PointerEvent("pointerup")); +} + +/** + * Tests the cover picker feature when unsplash credentials are unset. In this + * case, the "Add Cover" button should always open the cover selector. + */ +registry.category("web_tour.tours").add("knowledge_cover_selector_tour", { + checkDelay: 100, + url: "/odoo", + steps: () => [ + stepUtils.showAppsMenuItem(), + { + content: "Open Knowledge App", + trigger: '.o_app[data-menu-xmlid="knowledge.knowledge_menu_root"]', + run: "click", + }, + { + content: "Click on the 'Create' button", + trigger: ".o_knowledge_header .btn-create", + run: "click", + }, + { + trigger: '.o_article_active:contains("Untitled")', + }, + { + content: "Set the name of the article", + trigger: ".o_hierarchy_article_name > input", + run: "edit Birds && click body", + }, + { + content: "Make the add cover button visible (only visible on hover)", + trigger: '.o_article_active:contains("Birds")', + }, + { + content: "click on toggle menu", + trigger: "#dropdown_tools_panel[title='More actions']", + run: "click", + }, + { + content: "Click on add cover button", + trigger: ".o_knowledge_add_cover", + run: "click", + }, + { + trigger: ".modal-body .unsplash_error", + }, + { + // Check that the cover selector has been opened and that it shows + // the form allowing to enter unsplash credentials, and click on the + // add url button + trigger: ".o_upload_media_url_button", + }, + { + trigger: ".modal-body .o_nocontent_help", + }, + { + content: "Change the search query to find odoo_logo file", + trigger: ".modal-body input.o_we_search", + run: "edit odoo_logo", + }, + { + content: "Choose the odoo_logo cover", + trigger: '.o_existing_attachment_cell img[title*="odoo_logo"]', + run: "click", + }, + { + content: + "Check cover has been added to the article and is initially centered, make the reposition cover button visible", + trigger: '.o_knowledge_cover img[style="object-position: 50% 50%;"]', + run: "hover && click .o_knowledge_reposition_cover", + }, + { + content: "Move the cover down and click on the 'Cancel' button", + trigger: ".o_reposition_hint", + run: () => { + moveCover(1000); + const undoButton = document.querySelector(".o_knowledge_undo_cover_move"); + // Timeout to make sure the event is fired after that the cover has moved + setTimeout( + () => + undoButton.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true }) + ), + 0 + ); + }, + }, + { + trigger: ".o_knowledge_cover:not(:has(.o_reposition_hint))", + }, + { + content: "Check that the undo button works as expected (cover should be centered)", + trigger: '.o_knowledge_cover img[style="object-position: 50% 50%;"]', + // Move cover again but use the 'save' button this time + run: "hover && click .o_knowledge_reposition_cover", + }, + { + trigger: ".o_reposition_hint", + run: () => { + moveCover(1000); + const saveButton = document.querySelector(".o_knowledge_save_cover_move"); + // Timeout to make sure the event is fired after that the cover has moved + setTimeout( + () => + saveButton.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true }) + ), + 0 + ); + }, + }, + { + trigger: ".o_knowledge_cover:not(:has(.o_reposition_hint))", + }, + { + content: "Check that the cover is positioned at the top", + trigger: '.o_knowledge_cover img[style="object-position: 50% 0.01%;"]', + run: "click", + }, + { + content: "Create another article", + trigger: ".o_knowledge_header .btn-create", + run: "click", + }, + { + trigger: '.o_article_active:contains("Untitled")', + }, + { + content: "Change the name of the article", + trigger: ".o_hierarchy_article_name > input", + run: "edit odoo && click body", + }, + { + trigger: ".o_article_active:contains(odoo)", + }, + { + content: "Go back to previous article", + trigger: '.o_knowledge_sidebar .o_article_name:contains("Birds")', + run: "click", + }, + { + trigger: '.o_article_active:contains("Birds")', + }, + { + content: + "Check that the cover is still positioned at the top and make the replace cover visible", + trigger: '.o_knowledge_cover img[style="object-position: 50% 0.01%;"]', + run: "hover && click .o_knowledge_replace_cover", + }, + { + trigger: ".modal-body .o_nocontent_help", + }, + { + // Check that the cover selector has been opened, that no image is shown + // since the search query (birds) do not match the name of the existing + // cover, and close the cover selector + trigger: ".modal-footer .btn-secondary", + run: "click", + }, + { + content: "Make the remove cover button visible and click on it", + trigger: ".o_knowledge_cover", + run: "hover && click .o_knowledge_remove_cover", + }, + { + content: "Check cover has been removed from the article", + trigger: ".o_knowledge_body:not(:has(.o_widget_knowledge_cover))", + }, + { + content: "Open other article", + trigger: ".o_knowledge_sidebar .o_article_name:contains(odoo)", + run: "click", + }, + { + trigger: ".o_article_active:contains(odoo)", + }, + { + content: "click on toggle menu", + trigger: "#dropdown_tools_panel[title='More actions']", + run: "click", + }, + { + content: "Click on add cover button", + trigger: ".o_knowledge_add_cover", + run: "click", + }, + { + // Check that odoo logo previously uploaded is shown in the selector as the + // search query, which is the article name, is "odoo" which is also in the + // cover attachment's name, and that clicking on it sets it as cover of the + // current article + trigger: '.modal-body .o_existing_attachment_cell img[title="odoo_logo.png"]', + run: "click", + }, + { + content: "check the cover is in odoo article", + trigger: ".o_knowledge_cover", + }, + { + content: "Open previous article again", + trigger: '.o_knowledge_sidebar .o_article_name:contains("Birds")', + run: "click", + }, + { + content: "click on toggle menu", + trigger: "#dropdown_tools_panel[title='More actions']", + run: "click", + }, + { + content: "Click on add cover button", + trigger: ".o_knowledge_add_cover", + run: "click", + }, + { + trigger: ".modal-body .o_nocontent_help", + }, + { + content: + "Check odoo logo is not shown as the search query does not match its name and remove search query", + trigger: ".modal-body input.o_we_search", + run: "clear", + }, + { + content: + "Check that Odoo logo is now shown in the cover selector, make the trash button visible and click on delete cover button", + trigger: '.modal-body .o_existing_attachment_cell img[title="odoo_logo.png"]', + run: `hover && click .modal-body .o_existing_attachment_cell:has(img[title="odoo_logo.png"]) .o_existing_attachment_remove`, + }, + { + content: "Confirm deletion of cover (should ask for confirmation)", + trigger: ".modal:contains(Confirmation) .modal-footer .btn-primary", + run: "click", + }, + { + content: "Check that no cover is shown anymore in the cover selector", + trigger: + ".modal:contains(choose a nice cover) .modal-body .o_we_existing_attachments:not(:has(.o_existing_attachment_cell))", + }, + { + content: "Close it", + trigger: ".modal:contains(choose a nice cover) .modal-footer .btn-secondary", + run: "click", + }, + { + content: + "Open other article to check that its cover has been removed since it has been deleted", + trigger: ".o_knowledge_sidebar .o_article_name:contains(odoo)", + run: "click", + }, + { + trigger: ".o_article_active:contains(odoo)", + }, + { + trigger: ".o_knowledge_body:not(:has(.o_widget_knowledge_cover))", + }, + ...endKnowledgeTour(), + ], +}); diff --git a/addons_extensions/knowledge/static/tests/tours/knowledge_cover_random_unplash.js b/addons_extensions/knowledge/static/tests/tours/knowledge_cover_random_unplash.js new file mode 100644 index 000000000..8ce657f4a --- /dev/null +++ b/addons_extensions/knowledge/static/tests/tours/knowledge_cover_random_unplash.js @@ -0,0 +1,88 @@ +/** @odoo-module */ + +import { endKnowledgeTour, makeVisible } from './knowledge_tour_utils.js'; +import { registry } from "@web/core/registry"; +import { stepUtils } from "@web_tour/tour_service/tour_utils"; + +/** + * Tests the cover picker feature when unsplash credentials are set. In this + * case, the "Add Cover" button should either add a random picture from a + * selected unsplash collection if no name is set on the article, either + * add a random image using the article name as query word. + */ +registry.category("web_tour.tours").add('knowledge_random_cover_tour', { + url: '/odoo', + steps: () => [stepUtils.showAppsMenuItem(), { + // Open Knowledge App + trigger: '.o_app[data-menu-xmlid="knowledge.knowledge_menu_root"]', + run: "click", +}, { + // Click on the "Create" action + trigger: '.o_knowledge_header .btn-create', + run: "click", +}, { + // Make the add cover button visible (only visible on hover) + trigger: '.o_article_active:contains("Untitled")', + run: () => makeVisible('.o_knowledge_add_cover'), +}, { + // Click on add cover button + trigger: '.o_knowledge_add_cover', + run: "click", +}, { + // Check that a cover has been added, and make the change cover button visible + trigger: '.o_knowledge_cover .o_knowledge_cover_image', + run: () => makeVisible('.o_knowledge_replace_cover'), +}, { + // Click on change cover button + trigger: '.o_knowledge_replace_cover', + run: "click", +}, +{ + isActive: ["auto"], + trigger: ".modal-body .o_load_done_msg", +}, +{ + // Check that the cover selector has been opened, that no unsplash images can be + // loaded as the article has no name and close the cover selector + trigger: '.modal-footer .btn-secondary', + run: "click", +}, { + // Make the remove cover button visible + trigger: '.o_knowledge_edit_cover_buttons', + run: () => makeVisible('.o_knowledge_remove_cover'), +}, { + // Remove the cover of the article + trigger: '.o_knowledge_remove_cover', + run: "click", +}, { + // Set the name of the article + trigger: '.o_hierarchy_article_name > input', + run: "edit Birds && click body", +}, { + // Make the add cover button visible + trigger: '.o_article_active:contains("Birds")', + run: () => makeVisible('.o_knowledge_add_cover'), +}, { + // Click on add cover button + trigger: '.o_knowledge_add_cover', + run: "click", +}, { + // Check that a cover has been added and make the change cover button visible + trigger: '.o_knowledge_cover .o_knowledge_cover_image', + run: () => makeVisible('.o_knowledge_replace_cover'), +}, { + // Click on change cover button + trigger: '.o_knowledge_replace_cover', + run: "click", +}, +{ + isActive: ["auto"], + trigger: ".modal-body .o_load_more", +}, +{ + // Check that the cover selector has been opened, that other unsplash + // images can be loaded and close the cover selector + trigger: '.modal-footer .btn-secondary', + run: "click", +}, ...endKnowledgeTour() +]}); diff --git a/addons_extensions/knowledge/static/tests/tours/knowledge_embedded_views_tour.js b/addons_extensions/knowledge/static/tests/tours/knowledge_embedded_views_tour.js new file mode 100644 index 000000000..212645462 --- /dev/null +++ b/addons_extensions/knowledge/static/tests/tours/knowledge_embedded_views_tour.js @@ -0,0 +1,47 @@ +/** @odoo-module */ + +import { registry } from "@web/core/registry"; +import { stepUtils } from "@web_tour/tour_service/tour_utils"; +import { endKnowledgeTour, openCommandBar } from './knowledge_tour_utils'; + +registry.category("web_tour.tours").add('knowledge_embedded_view_filters_tour', { + url: '/odoo', + steps: () => [stepUtils.showAppsMenuItem(), { + // open Knowledge App + trigger: '.o_app[data-menu-xmlid="knowledge.knowledge_menu_root"]', + run: "click", + }, { // open the command bar + trigger: '.odoo-editor-editable > p', + run: function () { + openCommandBar(this.anchor); + }, + }, { // add embedded list view of article items + trigger: '.o-we-command-name:contains("Item List")', + run: "click", + }, { + trigger: '.btn-primary', + run: "click", + }, { // Check that we have 2 elements in the embedded view + trigger: 'tbody tr.o_data_row:nth-child(2)', + }, { // add a simple filter + trigger: '.o_searchview_input_container input', + run: "edit 1", + }, { + trigger: 'li#1', + run: "click", + }, { // Check that the filter is effective + trigger: 'tbody:not(tr.o_data_row:nth-child(2))', + }, { // Open the filtered article + trigger: 'tbody > tr > td[name="display_name"]', + run: "click", + }, { // Wait for the article to be open + trigger: '.o_hierarchy_article_name input:value("Child 1")', + }, { // Go back via the breadcrumbs go back button + trigger: '.o_knowledge_header i.oi-chevron-left', + run: "click", + }, { // Check that there is the filter in the searchBar + trigger: '.o_searchview_input_container > div', + }, { // Check that the filter is effective + trigger: 'tbody:not(tr.o_data_row:nth-child(2))', + }, ...endKnowledgeTour()] +}); diff --git a/addons_extensions/knowledge/static/tests/tours/knowledge_history_tour.js b/addons_extensions/knowledge/static/tests/tours/knowledge_history_tour.js new file mode 100644 index 000000000..2a60ddf0a --- /dev/null +++ b/addons_extensions/knowledge/static/tests/tours/knowledge_history_tour.js @@ -0,0 +1,114 @@ +/** @odoo-module */ + +/** + * Knowledge history tour. + * Features tested: + * - Create / edit an article an ensure revisions are created on write + * - Open the history dialog and check that the revisions are correctly shown + * - Select a revision and check that the content / comparison are correct + * - Click the restore button and check that the content is correctly restored + */ + +import { endKnowledgeTour } from '@knowledge/../tests/tours/knowledge_tour_utils'; +import { registry } from "@web/core/registry"; +import { stepUtils } from "@web_tour/tour_service/tour_utils"; + +const testArticleName = 'Test history Article'; +function changeArticleContentAndSave(newContent) { + return [ { + // change the content of the article + trigger: '.note-editable.odoo-editor-editable h1', + run: `editor ${newContent}`, // modify the article content + }, { + // reload knowledge articles to make sure that the article is saved + trigger: 'a[data-menu-xmlid="knowledge.knowledge_menu_home"]', + run: "click", + }, { + // wait for the page to reload and OWL to accept value change + trigger: '.o_article:contains("' + testArticleName + '"):not(.o_article_active)', + run: async () => { + await new Promise((r) => setTimeout(r, 300)); + }, + }, { + // click on the test article + trigger: '.o_article:contains("' + testArticleName + '") a.o_article_name', + run: "click", + }, { + // wait for the article to be loaded + trigger: '.o_article_active:contains("' + testArticleName + '") ', + }]; +} + + +registry.category("web_tour.tours").add('knowledge_history_tour', { + url: '/odoo', + steps: () => [stepUtils.showAppsMenuItem(), { + // open Knowledge App + trigger: '.o_app[data-menu-xmlid="knowledge.knowledge_menu_root"]', + run: "click", + }, { + // click on the main "New" action + trigger: '.o_knowledge_header .btn:contains("New")', + run: "click", + }, { + // check that the article is correctly created (private section) + trigger: 'section[data-section="private"] .o_article .o_article_name:contains("Untitled")', + }, + ...changeArticleContentAndSave(testArticleName), + ...changeArticleContentAndSave('Modified Title 01'), + ...changeArticleContentAndSave('Modified Title 02'), + ...changeArticleContentAndSave('Modified Title 03'), + { + // Open history dialog + trigger: '.btn.btn-history', + run: "click", + }, { + // check the history dialog is opened + trigger: '.modal-header:contains("History")', + run: "click", + }, { + // check that we have the correct number of revision (4) + trigger: ".html-history-dialog .revision-list .btn", + run: function () { + const items = document.querySelectorAll(".revision-list .btn"); + if (items.length !== 4) { + throw new Error('Expect 4 Revisions in the history dialog, got ' + items.length); + } + }, + }, { + // check the first revision content is correct + trigger: '.history-container .tab-pane:contains("Modified Title 02")', + run: "click", + }, { + // click on the 3rd revision + trigger: '.html-history-dialog .revision-list .btn:nth-child(3)', + run: "click", + }, { + // check the 3rd revision content is correct + trigger: '.history-container .tab-pane:contains("' + testArticleName + '")', + run: "click", + }, { + // click on the comparison tab + trigger: '.history-container .nav-item:contains(Comparison) a', + run: "click", + }, { + // check the comparison content is correct + trigger: '.history-container .tab-pane', + run: function () { + const comparisonHtml = document.querySelector('.history-container .tab-pane .o_readonly').innerHTML; + const correctHtml = 'Writable Subarticle through inheritance
', + 'favorite_ids': [ + (0, 0, {'user_id': cls.user_admin.id}), + (0, 0, {'user_id': cls.user_employee.id}), + (0, 0, {'user_id': cls.user_portal.id}), + ], + 'name': 'Writable Subarticle through inheritance', + 'parent_id': cls.article_headers[0].id, + }, + ]) + cls.article_write_contents_children = cls.env['knowledge.article'].create([ + {'name': 'Child of writable through inheritance', + 'parent_id': cls.article_write_contents[2].id, + }, + ]) + cls.article_write_contents_children += cls.env['knowledge.article'].create([ + {'name': 'Child of child of writable through inheritance', + 'parent_id': cls.article_write_contents_children[0].id, + }, + ]) + cls.article_write_desync = cls.env['knowledge.article'].create([ + # Community/Writable + {'article_member_ids': [ + (0, 0, {'partner_id': cls.partner_employee_manager.id, + 'permission': 'write', + }), + ], + 'internal_permission': 'read', + 'is_desynchronized': True, + 'name': 'Desync Nyarlathotep', + 'parent_id': cls.article_write_contents[2].id, + }, + ]) + cls.article_write_desync += cls.env['knowledge.article'].create([ + {'name': 'Childof Desync Nyarlathotep', + 'parent_id': cls.article_write_desync[0].id, + }, + ]) + + # Under Read internal permission + cls.article_read_contents = cls.env['knowledge.article'].create([ + # TTRPG + {'article_member_ids': [ + (0, 0, {'partner_id': cls.partner_portal.id, + 'permission': 'read', + }), + ], + 'internal_permission': 'write', + 'name': 'OpenCthulhu', + 'parent_id': cls.article_headers[1].id, + }, + {'article_member_ids': [ + (0, 0, {'partner_id': cls.partner_portal.id, + 'permission': 'read', + }), + (0, 0, {'partner_id': cls.partner_employee.id, + 'permission': 'read', + }), + ], + 'internal_permission': 'write', + 'name': 'Open Paranoïa', + 'parent_id': cls.article_headers[1].id, + }, + {'name': 'Proprietary RPGs', + 'parent_id': cls.article_headers[1].id, + }, + {'internal_permission': 'none', + 'name': 'Secret RPGs', + 'parent_id': cls.article_headers[1].id, + }, + ]) + cls.article_read_contents_children = cls.env['knowledge.article'].create([ + {'name': 'Child of Secret RPGs', + 'parent_id': cls.article_read_contents[3].id, + }, + ]) + cls.article_read_desync = cls.env['knowledge.article'].create([ + # Read/TTRPG: Open Cthulhu + {'article_member_ids': [ + (0, 0, {'partner_id': cls.partner_employee.id, + 'permission': 'write', + }), + (0, 0, {'partner_id': cls.partner_employee_manager.id, + 'permission': 'read', + }), + ], + 'internal_permission': 'none', + 'is_desynchronized': True, + 'name': 'Mansions of Terror', + 'parent_id': cls.article_read_contents[0].id, + }, + ]) + cls.article_read_desync += cls.env['knowledge.article'].create([ + {'name': 'Childof Desync Mansions', + 'parent_id': cls.article_read_desync[0].id, + }, + ]) + + cls.articles_all = cls.article_roots + cls.article_headers + \ + cls.article_write_contents + cls.article_write_contents_children + \ + cls.article_read_contents + cls.article_read_contents_children + \ + cls.article_write_desync + cls.article_read_desync + cls.env.flush_all() diff --git a/addons_extensions/knowledge/tests/test_knowledge_article_business.py b/addons_extensions/knowledge/tests/test_knowledge_article_business.py new file mode 100644 index 000000000..a3b14397d --- /dev/null +++ b/addons_extensions/knowledge/tests/test_knowledge_article_business.py @@ -0,0 +1,1766 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import json + +from datetime import datetime, timedelta +from lxml import html +from unittest.mock import patch +from urllib import parse + +from odoo import exceptions +from odoo.addons.knowledge.tests.common import KnowledgeCommon, KnowledgeCommonWData +from odoo.exceptions import UserError +from odoo.tests.common import tagged, users +from odoo.tools import mute_logger + + +@tagged('knowledge_internals', 'knowledge_management') +class KnowledgeCommonBusinessCase(KnowledgeCommonWData): + + @classmethod + def setUpClass(cls): + """ Add some hierarchy to have mixed rights tests """ + super().setUpClass() + + # - Private seq=997 private none (manager-w+) + # - Child1 seq=0 " " + # - Shared seq=998 shared none (admin-w+,employee-r+,manager-r+) + # - Child1 seq=0 " " (employee-w+) + # - Child2 seq=0 " " (portal-r+) + # - Child3 seq=0 " " (admin-w+,employee-n-) + # - Playground seq=999 workspace w+ + # - Child1 seq=0 " " + # - Gd Child1 + # - Gd Child2 + # - GdGd Child1 + # - Child2 seq=1 " " + # - Gd Child1 + # - GdGd Child2 + + cls.shared_children += cls.env['knowledge.article'].sudo().create([ + {'article_member_ids': [ + (0, 0, {'partner_id': cls.partner_admin.id, + 'permission': 'write', + }), + (0, 0, {'partner_id': cls.partner_employee.id, + 'permission': 'none', + }), + ], + 'internal_permission': False, + 'name': 'Shared Child3', + 'parent_id': cls.article_shared.id, + }, + ]) + + # to test descendants computation, add some sub children + cls.wkspace_grandchildren = cls.env['knowledge.article'].create([ + {'name': 'Grand Children of workspace', + 'parent_id': cls.workspace_children[0].id, + }, + {'name': 'Grand Children of workspace', + 'parent_id': cls.workspace_children[0].id, + }, + {'name': 'Grand Children of workspace', + 'parent_id': cls.workspace_children[1].id, + } + ]) + cls.wkspace_grandgrandchildren = cls.env['knowledge.article'].create([ + {'name': 'Grand Grand Children of workspace', + 'parent_id': cls.wkspace_grandchildren[1].id, + }, + {'name': 'Grand Children of workspace', + 'parent_id': cls.wkspace_grandchildren[2].id, + }, + ]) + cls.env.flush_all() + + +@tagged('knowledge_internals', 'knowledge_management') +class TestKnowledgeArticleBusiness(KnowledgeCommonBusinessCase): + """ Test business API and main tools or helpers methods. """ + + @mute_logger('odoo.addons.base.models.ir_rule') + @users('employee') + def test_article_create(self): + """ Testing the helper to create articles with right values. """ + Article = self.env['knowledge.article'] + article = self.article_workspace.with_env(self.env) + readonly_article = self.article_shared.with_env(self.env) + + _title = 'Fthagn' + new = Article.article_create(title=_title, parent_id=False, is_private=False) + self.assertMembers(new, 'write', {self.env.user.partner_id: 'write'}) # With the visibility we add directly the user as member + self.assertEqual(new.body, f'Hello world
' + + render_embedded_view({ + 'action_xml_id': 'knowledge.knowledge_article_item_action', + 'display_name': 'Kanban', + 'view_type': 'kanban', + 'context': { + 'active_id': article.id, + 'default_parent_id': article.id, + 'default_icon': '📄', + 'default_is_article_item': True, + } + }) + + render_embedded_view({ + 'action_xml_id': 'knowledge.knowledge_article_item_action', + 'display_name': 'List', + 'view_type': 'list', + 'context': { + 'active_id': article.id, + 'default_parent_id': article.id, + 'default_icon': '📄', + 'default_is_article_item': True, + } + }) + + render_embedded_view({ + 'action_xml_id': 'knowledge.knowledge_article_action', + 'display_name': 'Articles', + 'view_type': 'list', + 'context': { + 'search_default_filter_trashed': 1, + } + }) + ) + }) + + expected_view_types = [ + 'kanban', + 'list', + 'list' + ] + expected_contexts = [{ + 'active_id': article.id, + 'default_parent_id': article.id, + 'default_icon': '📄', + 'default_is_article_item': True, + }, { + 'active_id': article.id, + 'default_parent_id': article.id, + 'default_icon': '📄', + 'default_is_article_item': True, + }, { + 'search_default_filter_trashed': 1, + }] + + fragment = html.fragment_fromstring(article.body, create_parent=True) + embedded_views = list(fragment.findall('.//*[@data-embedded="view"]')) + + # Check that the original article contains the embedded views we want + self.assertEqual(len(embedded_views), 3) + for (embedded_view, expected_view_type, expected_context) in zip(embedded_views, expected_view_types, expected_contexts): + embedded_props = json.loads(parse.unquote(embedded_view.get('data-embedded-props', {}))) + self.assertEqual(embedded_props['view_type'], expected_view_type) + self.assertEqual(embedded_props['context'], expected_context) + + # Copy the article + new_article = article.action_make_private_copy() + + expected_contexts = [{ + 'active_id': new_article.id, + 'default_parent_id': new_article.id, + 'default_icon': '📄', + 'default_is_article_item': True, + }, { + 'active_id': new_article.id, + 'default_parent_id': new_article.id, + 'default_icon': '📄', + 'default_is_article_item': True, + }, { + 'search_default_filter_trashed': 1, + }] + + fragment = html.fragment_fromstring(new_article.body, create_parent=True) + embedded_views = list(fragment.findall('.//*[@data-embedded="view"]')) + + # Check that the context of the embedded views stored in the body of the + # newly created article have properly been updated: The embedded views + # listing the article items of the original article should now list the + # article items of the copy. + + self.assertEqual(len(embedded_views), 3) + for (embedded_view, expected_view_type, expected_context) in zip(embedded_views, expected_view_types, expected_contexts): + embedded_props = json.loads(parse.unquote(embedded_view.get('data-embedded-props', {}))) + self.assertEqual(embedded_props['view_type'], expected_view_type) + self.assertEqual(embedded_props['context'], expected_context) + + @mute_logger('odoo.addons.base.models.ir_model', 'odoo.addons.base.models.ir_rule') + @users('employee') + def test_copy(self): + article_hidden = self.article_private_manager.with_env(self.env) + with self.assertRaises(exceptions.AccessError, + msg="ACLs: copy should not allow to access hidden articles"): + _new_article = article_hidden.copy() + + # Copying an article should create a private article without parent nor children + article_readonly = self.article_shared.with_env(self.env) + with self.assertRaises(exceptions.AccessError, + msg="ACLs: copy should not allow to access readonly articles members"): + _new_article = article_readonly.copy() + + # Copy an accessible article + article_workspace = self.article_workspace.with_env(self.env) + new_article = article_workspace.copy() + self.assertEqual(new_article.name, f'{article_workspace.name} (copy)') + self.assertMembers( + new_article, + 'write', + {} + ) + self.assertEqual(len(new_article.child_ids), 2, 'Copy: should copy children') + self.assertTrue(new_article.child_ids != article_workspace.child_ids) + self.assertEqual( + sorted(new_article.child_ids.mapped('name')), + sorted([f"{name}" for name in article_workspace.child_ids.mapped('name')]) + ) + self.assertFalse(new_article.parent_id) + + +@tagged('knowledge_internals', 'knowledge_management') +class TestKnowledgeArticleRemoval(KnowledgeCommonBusinessCase): + """ Test unlink / archive management of articles """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.shared_article_multi_company = cls.env['knowledge.article'].create({ + 'name': "Multi-Company Article", + 'article_member_ids': [ + (0, 0, {'partner_id': cls.partner_employee_c2.id, 'permission': 'read'}), + (0, 0, {'partner_id': cls.partner_employee.id, 'permission': 'write'}), + (0, 0, {'partner_id': cls.partner_admin.id, 'permission': 'write'}) + ], + 'internal_permission': 'none' + }) + + @users('employee') + def test_send_to_trash_multi_company(self): + article_to_trash = self.shared_article_multi_company + article_to_trash.action_send_to_trash() + self.assertFalse(article_to_trash.active) + self.assertTrue(article_to_trash.to_delete) + + @mute_logger('odoo.addons.base.models.ir_rule') + @users('employee') + def test_archive(self): + """ Testing archive that should also archive children. """ + self._test_archive(test_trash=False) + + def test_archive_unactive_article(self): + """ Checking that an unactive article can be archived. """ + article = self.env['knowledge.article'].create({'name': 'Article'}) + self.assertTrue(article.active) + self.assertFalse(article.to_delete) + + article.action_archive() + self.assertFalse(article.active) + self.assertFalse(article.to_delete) + + article.action_send_to_trash() + self.assertFalse(article.active) + self.assertTrue(article.to_delete) + + def _test_archive(self, test_trash=False): + archive_method_name = 'action_send_to_trash' if test_trash else 'action_archive' + + article_shared = self.article_shared.with_env(self.env) + article_workspace = self.article_workspace.with_env(self.env) + wkspace_children = self.workspace_children.with_env(self.env) + # to test descendants computation, add some sub children + wkspace_grandchildren = self.wkspace_grandchildren.with_env(self.env) + wkspace_grandgrandchildren = self.wkspace_grandgrandchildren.with_env(self.env) + + # no write access -> cracboum + with self.assertRaises(exceptions.AccessError, + msg='Employee can read thus not archive'): + getattr(article_shared, archive_method_name)() + + # set the root + children inactive + getattr(article_workspace, archive_method_name)() + self.assertFalse(article_workspace.active) + self.assertEqual(article_workspace.to_delete, test_trash) + for article in wkspace_children + wkspace_grandchildren + wkspace_grandgrandchildren: + self.assertFalse(article.active, 'Archive: should propagate to children') + self.assertEqual(article.root_article_id, article_workspace, + 'Archive: does not change hierarchy when archiving without breaking hierarchy') + self.assertEqual(article.to_delete, test_trash) + + # reset as active + articles_to_restore = article_workspace + wkspace_children + wkspace_grandchildren + wkspace_grandgrandchildren + articles_to_restore.action_unarchive() + for article in articles_to_restore: + self.assertTrue(article.active) + self.assertFalse(article.to_delete) + + # set only part of tree inactive + getattr(wkspace_children, archive_method_name)() + self.assertTrue(article_workspace.active) + self.assertFalse(article_workspace.to_delete) + for article in wkspace_children + wkspace_grandchildren + wkspace_grandgrandchildren: + self.assertFalse(article.active, 'Archive: should propagate to children') + self.assertEqual(article.to_delete, test_trash, 'Trash: should propagate to children') + + @mute_logger('odoo.addons.base.models.ir_rule') + @users('employee') + def test_archive_mixed_rights(self): + self._test_archive_mixed_rights(test_trash=False) + + def _test_archive_mixed_rights(self, test_trash=False): + """ Test archive in case of mixed rights """ + # give write access to shared section, but have children in read or none + # and add a customer on top of shared articles to check propagation + archive_method_name = 'action_send_to_trash' if test_trash else 'action_archive' + + self.article_shared.write({ + 'article_member_ids': [(0, 0, { + 'partner_id': self.customer.id, + 'permission': 'read', + })] + }) + self.article_shared.article_member_ids.sudo().filtered( + lambda article: article.partner_id == self.partner_employee + ).write({'permission': 'write'}) + self.shared_children[1].write({ + 'article_member_ids': [(0, 0, {'partner_id': self.partner_employee.id, + 'permission': 'read'})] + }) + + # prepare comparison data as sudo + writable_child_su = self.article_shared.child_ids.filtered( + lambda article: article.name in ['Shared Child1']) + readonly_child_su = self.article_shared.child_ids.filtered( + lambda article: article.name in ['Shared Child2']) + hidden_child_su = self.article_shared.child_ids.filtered( + lambda article: article.name in ['Shared Child3']) + + # perform archive as user + article_shared = self.article_shared.with_env(self.env) + article_shared.invalidate_model(['child_ids']) # context dependent + shared_children = article_shared.child_ids + writable_child, readonly_child = writable_child_su.with_env(self.env), readonly_child_su.with_env(self.env) + self.assertEqual(len(shared_children), 2) + self.assertFalse(readonly_child.user_has_write_access) + self.assertTrue(writable_child.user_has_write_access) + self.assertEqual(shared_children, writable_child + readonly_child, + 'Should see only two first children') + + getattr(article_shared, archive_method_name)() + # check writable articles have been archived, readonly or hidden not + self.assertFalse(article_shared.active) + self.assertEqual(article_shared.to_delete, test_trash) + self.assertFalse(writable_child.active) + self.assertEqual(writable_child.to_delete, test_trash) + self.assertTrue(readonly_child.active) + self.assertFalse(readonly_child.to_delete) + self.assertTrue(hidden_child_su.active) + self.assertFalse(hidden_child_su.to_delete) + + # check hierarchy + self.assertEqual(writable_child.parent_id, article_shared, + 'Archive: archived articles hierarchy does not change') + self.assertFalse(readonly_child.parent_id, 'Archive: article should be extracted in archive process as non writable') + self.assertEqual(readonly_child.root_article_id, readonly_child) + self.assertFalse(hidden_child_su.parent_id, 'Archive: article should be extracted in archive process as non writable') + self.assertEqual(hidden_child_su.root_article_id, hidden_child_su) + + # verify that the child that was not accessible was moved as a root article... + self.assertTrue(hidden_child_su.active) + self.assertEqual(hidden_child_su.category, 'shared') + self.assertEqual(hidden_child_su.internal_permission, 'none') + self.assertFalse(hidden_child_su.parent_id) + # ... and kept his access rights: still member for employee / admin and + # copied customer access from the archived parent + self.assertMembers( + hidden_child_su, + 'none', + {self.user_admin.partner_id: 'write', + self.partner_employee_manager: 'read', + self.partner_employee: 'none', + self.customer: 'read', + } + ) + + # Test that articles removed from trash are made roots if their parent are still in the Trash. + if test_trash: + writable_child.action_unarchive() + self.assertFalse(writable_child.to_delete) + self.assertFalse(writable_child.parent_id) + self.assertTrue(article_shared.to_delete) + self.assertTrue(writable_child not in article_shared.with_context( + active_test=False).child_ids) + self.assertEqual(writable_child.internal_permission, + article_shared.internal_permission) + # Note: could be different if writable_child had custom partners. Not the case here so we can use '=='. + self.assertTrue(article_shared.article_member_ids.partner_id == writable_child.article_member_ids.partner_id) + + @mute_logger('odoo.addons.base.models.ir_rule') + @users('employee') + def test_trashed(self): + """ Testing 'send to trash' that should also trash children. """ + self._test_archive(test_trash=True) + + @mute_logger('odoo.addons.base.models.ir_rule') + @users('employee') + def test_trashed_mixed_rights(self): + """ Test Trash in case of mixed rights """ + self._test_archive_mixed_rights(test_trash=True) + + @mute_logger('odoo.addons.base.models.ir_model', 'odoo.addons.base.models.ir_rule', 'odoo.models.unlink') + @users('admin') + def test_unlink_admin(self): + """ Admin (system) has access to unlink, test propagation and effect + on children. """ + article_shared = self.article_shared.with_env(self.env) + article_shared.unlink() + self.assertFalse( + (self.article_shared + self.shared_children).exists(), + 'Unlink: should also unlink children' + ) + + @mute_logger('odoo.addons.base.models.ir_model', 'odoo.addons.base.models.ir_rule', 'odoo.models.unlink') + @users('employee') + def test_unlink_employee(self): + """ Employee cannot unlink anyway """ + article_hidden = self.article_private_manager.with_env(self.env) + with self.assertRaises(exceptions.AccessError, + msg="ACLs: uhnlink is not accessible to employees"): + article_hidden.unlink() + + article_workspace = self.article_workspace.with_env(self.env) + with self.assertRaises(exceptions.AccessError, + msg="ACLs: unlink is not accessible to employees"): + article_workspace.unlink() + + def test_unarchive_article_items_having_archived_parent(self): + """ Check that the user can not unarchive an article item whose parent is archived. """ + parent_article = self.env['knowledge.article'].create({ + 'active': False, + 'to_delete': True, + 'name': 'Parent article', + }) + article_item = self.env['knowledge.article'].create({ + 'active': False, + 'to_delete': True, + 'name': 'Article item', + 'parent_id': parent_article.id, + 'is_article_item': True, + }) + + with self.assertRaises(UserError): + article_item.action_unarchive() + + (parent_article + article_item).action_unarchive() + self.assertTrue(parent_article.active) + self.assertFalse(parent_article.to_delete) + self.assertTrue(article_item.active) + self.assertFalse(article_item.to_delete) + self.assertEqual(article_item.parent_id, parent_article) + + + @users('employee') + def test_unarchive_article_having_inaccessible_parent(self): + """ Check that the user can restore an article whose parent is inaccessible. """ + + parent_article = self.env['knowledge.article'].sudo().create({ + 'active': False, + 'to_delete': True, + 'name': 'Parent article', + 'internal_permission': 'write', + 'article_member_ids': [ + (0, 0, { + 'partner_id': self.env.user.partner_id.id, + 'permission': 'none' + }), + (0, 0, { + 'partner_id': self.customer.id, + 'permission': 'read' + }), + ] + }).with_user(self.env.user) + + child_article = self.env['knowledge.article'].sudo().create({ + 'active': False, + 'to_delete': True, + 'name': 'Child article', + 'parent_id': parent_article.id, + 'article_member_ids': [ + (0, 0, { + 'partner_id': self.env.user.partner_id.id, + 'permission': 'write' + }), + ] + }).with_user(self.env.user) + + # Check the access rights: + parent_article.browse().check_access('read') + with self.assertRaises(exceptions.AccessError): + parent_article.check_access('read') + child_article.check_access('write') + + # Unarchive the child article: + child_article.action_unarchive() + + # When the parent article is in the trash, the child article should be + # detached from its parent so that it will not be deleted when the parent + # article is deleted. + + self.assertTrue(child_article.active) + self.assertFalse(child_article.to_delete) + self.assertFalse(child_article.parent_id) + self.assertFalse(child_article.is_desynchronized) + self.assertMembers(child_article, 'write', { + self.env.user.partner_id: 'write', + self.customer: 'read' + }) + + self.assertFalse(parent_article.active) + self.assertTrue(parent_article.to_delete) + self.assertMembers(parent_article, 'write', { + self.env.user.partner_id: 'none', + self.customer: 'read' + }) + + def test_trashed_article_garbage_collect(self): + [normal, trashed_not_to_unlink, archived_not_trashed] = self.env['knowledge.article'].create([{ + 'name': 'Normal article', + 'internal_permission': 'write', + }, { + 'active': False, + 'to_delete': True, + 'internal_permission': 'write', + 'name': 'Trashed not to unlink', + 'parent_id': False, + }, { + 'active': False, + 'internal_permission': 'write', + 'to_delete': False, + 'name': 'Only Archived', + 'parent_id': False, + }]) + + self.env['knowledge.article'].flush_model() + + before = datetime.now() - timedelta(days=self.env['knowledge.article'].DEFAULT_ARTICLE_TRASH_LIMIT_DAYS + 1) + # Older article + with patch.object(self.env.cr, 'now', return_value=before): + trashed_to_unlink = self.env['knowledge.article'].create({ + 'active': False, + 'internal_permission': 'write', + 'to_delete': True, + 'name': 'Trashed to unlink', + 'parent_id': False, + }) + self.env['knowledge.article'].flush_model() + + self.env['knowledge.article']._gc_trashed_articles() + self.assertFalse(trashed_to_unlink.exists()) + self.assertTrue(normal.exists()) + self.assertTrue(trashed_not_to_unlink.exists(), "This article's deletion date is not yet passed.") + self.assertTrue(archived_not_trashed.exists(), "This article is archived and not trashed (to_delete = False), it shouldn't be unlinked.") + +@tagged('post_install', '-at_install', 'knowledge_internals', 'knowledge_management') +class TestKnowledgeShare(KnowledgeCommonWData): + """ Test share feature. """ + def test_article_can_invite_members_with_wizard(self): + """Check that the administrator is allowed to invite a new member + without 'write' permission by using the invitation wizard.""" + article = self.env['knowledge.article'].create({ + 'name': 'My article', + 'internal_permission': 'write', + 'article_member_ids': [ + (0, 0, {'partner_id': self.partner_employee.id, 'permission': 'read'}), + (0, 0, {'partner_id': self.partner_admin.id, 'permission': 'read'}) + ] + }) + + self.assertFalse(article.with_user(self.user_employee).user_has_write_access) + self.assertFalse(article.with_user(self.user_admin).user_has_write_access) + + with self.assertRaises(exceptions.AccessError): + self.env['knowledge.invite'].with_user(self.user_employee).create({ + 'article_id': article.id, + 'partner_ids': self.partner_public, + 'permission': 'read', + }) + + self.assertMembers(article, 'write', { + self.partner_employee: 'read', + self.partner_admin: 'read' + }) + + self.env['knowledge.invite'].with_user(self.user_admin).create({ + 'article_id': article.id, + 'partner_ids': self.partner_public, + 'permission': 'read', + }).action_invite_members() + + self.assertMembers(article, 'write', { + self.partner_employee: 'read', + self.partner_admin: 'read', + self.partner_public: 'read' + }) + + @mute_logger('odoo.addons.base.models.ir_rule', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink', 'odoo.tests') + @users('employee2') + def test_knowledge_article_share(self): + # private article of "employee manager" + knowledge_article_sudo = self.env['knowledge.article'].sudo().create({ + 'name': 'Test Article', + 'body': 'Content
', + 'internal_permission': 'none', + 'article_member_ids': [(0, 0, { + 'partner_id': self.partner_employee_manager.id, + 'permission': 'write', + })], + }) + article = knowledge_article_sudo.with_env(self.env) + self.assertFalse(article.user_has_access) + + # employee2 is not supposed to be able to share it + with self.assertRaises(exceptions.AccessError): + self._knowledge_article_share(article, self.partner_portal.ids, 'read') + + # give employee2 read access on the document + knowledge_article_sudo.write({ + 'article_member_ids': [(0, 0, { + 'partner_id': self.partner_employee2.id, + 'permission': 'read', + })] + }) + self.assertTrue(article.user_has_access) + + # still not supposed to be able to share it + with self.assertRaises(exceptions.AccessError): + self._knowledge_article_share(article, self.partner_portal.ids, 'read') + + # modify employee2 access to write + knowledge_article_sudo.article_member_ids.filtered( + lambda member: member.partner_id == self.partner_employee2 + ).write({'permission': 'write'}) + + # now they should be able to share it + with self.mock_mail_gateway(), self.mock_mail_app(): + self._knowledge_article_share(article, self.partner_portal.ids, 'read') + + # check that portal user received an invitation link + self.assertEqual(len(self._new_msgs), 1) + self.assertIn( + knowledge_article_sudo._get_invite_url(self.partner_portal), + self._new_mails.body_html + ) + + with self.with_user('portal_test'): + # portal should now have read access to the article + # (re-browse to have the current user context for user_permission) + article_asportal = knowledge_article_sudo.with_env(self.env) + self.assertTrue(article_asportal.user_has_access) + + def _knowledge_article_share(self, article, partner_ids, permission='write'): + """ Re-browse the article to make sure we have the current user context on it. + Necessary for all access fields compute methods in knowledge.article. """ + + return self.env['knowledge.invite'].create({ + 'article_id': self.env['knowledge.article'].browse(article.id).id, + 'partner_ids': partner_ids, + 'permission': permission, + }).action_invite_members() + +@tagged('post_install', '-at_install', 'knowledge_internals', 'knowledge_management') +class TestKnowledgeArticleCovers(KnowledgeCommonWData): + """ Test article covers management """ + @users('employee') + def test_article_cover_management(self): + # User cannot modify cover of hidden article + article_hidden = self.article_private_manager.with_env(self.env) + cover = self._create_cover() + with self.assertRaises(exceptions.AccessError, + msg="Cannot add cover to hidden article"): + article_hidden.write({'cover_image_id': cover.id}) + article_hidden.with_user(self.user_admin).write({'cover_image_id': cover.id}) + with self.assertRaises(exceptions.AccessError, + msg="Cannot remove cover of hidden article"): + article_hidden.write({'cover_image_id': False}) + + # User cannot modify cover of readable article but has access to it + article_read = self.article_shared.with_env(self.env) + with self.assertRaises(exceptions.AccessError, + msg="Cannot add cover to readable article"): + article_read.write({'cover_image_id': cover.id}) + cover_2 = self._create_cover() + article_read.with_user(self.user_admin).write({'cover_image_id': cover_2.id}) + with self.assertRaises(exceptions.AccessError, + msg="Cannot remove cover of readable article"): + article_read.write({'cover_image_id': False}) + + # User can reuse a cover used in another article. + article_write = self.article_workspace.with_env(self.env) + article_write.write({'cover_image_id': cover_2.id}) + self.assertEqual(article_write.cover_image_id, cover_2) + + +@tagged('post_install', '-at_install', 'knowledge_internals', 'knowledge_management', 'knowledge_visibility') +class TestKnowledgeArticleVisibility(KnowledgeCommon): + """ Test suite checking that the articles can be marked as hidden. + This test suite should ensure that: + 1. Users can search for articles that are visible or hidden using the custom + search method on the `is_article_visible` computed field. + 2. The shared and private articles are always visible. + """ + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Articles: + Article = cls.env['knowledge.article'] + with mute_logger('odoo.models.unlink'): + Article.search([]).unlink() + + cls.workspace_article = Article.create({ + 'name': 'Workspace article' + }) + cls.shared_article = Article.create({ + 'name': 'Shared article', + 'internal_permission': 'none', + 'article_member_ids': [ + (0, 0, { + 'partner_id': cls.user_admin.partner_id.id, + 'permission': 'write' + }), + (0, 0, { + 'partner_id': cls.user_employee.partner_id.id, + 'permission': 'write' + }) + ] + }) + cls.private_article = Article.create({ + 'name': 'Private article', + 'internal_permission': 'none', + 'article_member_ids': [(0, 0, { + 'partner_id': cls.user_employee.partner_id.id, + 'permission': 'write' + })] + }) + + @users('employee') + def test_is_workspace_article_visible(self): + Article = self.env['knowledge.article'] + workspace_article = self.workspace_article.with_user(self.env.user) + + # When creating a new article in the workspace, the article should be + # hidden by default and shouldn't appear in the sidebar. + + self.assertEqual(workspace_article.category, 'workspace') + self.assertTrue(workspace_article.user_has_access) + self.assertFalse(workspace_article.is_article_visible) + self.assertFalse(workspace_article.is_article_visible_by_everyone) + + self.assertEqual(Article.search([]), self.workspace_article + self.shared_article + self.private_article) + self.assertEqual(Article.search([('is_article_visible', '=', True)]), self.shared_article + self.private_article) + self.assertEqual(Article.search([('is_article_visible', '=', False)]), self.workspace_article) + self.assertEqual(Article.search([('is_article_visible', '!=', True)]), self.workspace_article) + self.assertEqual(Article.search([('is_article_visible', '!=', False)]), self.shared_article + self.private_article) + + workspace_article.action_join() + self.assertMembers(workspace_article, 'write', {self.env.user.partner_id: 'write'}) + + # If the article is hidden and the user has explicit "read" or "write" + # permission on the article (with the membership), the article should + # become visible to the user. + + self.assertTrue(workspace_article.is_article_visible) + self.assertFalse(workspace_article.is_article_visible_by_everyone) + + self.assertEqual(Article.search([]), self.workspace_article + self.shared_article + self.private_article) + self.assertEqual(Article.search([('is_article_visible', '=', True)]), self.workspace_article + self.shared_article + self.private_article) + self.assertFalse(Article.search([('is_article_visible', '=', False)])) + self.assertFalse(Article.search([('is_article_visible', '!=', True)])) + self.assertEqual(Article.search([('is_article_visible', '!=', False)]), self.workspace_article + self.shared_article + self.private_article) + + # When setting the `is_article_visible_by_everyone` field to `True`, the + # article should become visible to everyone (with the exception of those + # having no access to the article). + + workspace_article.set_is_article_visible_by_everyone(True) + + self.assertTrue(workspace_article.is_article_visible) + self.assertTrue(workspace_article.is_article_visible_by_everyone) + + self.assertEqual(Article.search([]), self.workspace_article + self.shared_article + self.private_article) + self.assertEqual(Article.search([('is_article_visible', '=', True)]), self.workspace_article + self.shared_article + self.private_article) + self.assertFalse(Article.search([('is_article_visible', '=', False)])) + self.assertFalse(Article.search([('is_article_visible', '!=', True)])) + self.assertEqual(Article.search([('is_article_visible', '!=', False)]), self.workspace_article + self.shared_article + self.private_article) + + # Removing the user from the member list should not change the visibility + # status of the article. + + for member in workspace_article.article_member_ids: + workspace_article._remove_member(member) + + self.assertTrue(workspace_article.is_article_visible) + self.assertTrue(workspace_article.is_article_visible_by_everyone) + + self.assertEqual(Article.search([]), self.workspace_article + self.shared_article + self.private_article) + self.assertEqual(Article.search([('is_article_visible', '=', True)]), self.workspace_article + self.shared_article + self.private_article) + self.assertFalse(Article.search([('is_article_visible', '=', False)])) + self.assertFalse(Article.search([('is_article_visible', '!=', True)])) + self.assertEqual(Article.search([('is_article_visible', '!=', False)]), self.workspace_article + self.shared_article + self.private_article) + + @users('employee') + def test_is_private_article_visible(self): + Article = self.env['knowledge.article'] + private_article = self.private_article.with_user(self.env.user) + + # For the private articles, the articles should always be visible even + # if the article is not marked as visible to everyone and the user does + # not have explicit "read" or "write" permission on the article. + + self.assertEqual(private_article.category, 'private') + self.assertTrue(private_article.user_has_access) + self.assertTrue(private_article.is_article_visible) + self.assertFalse(private_article.is_article_visible_by_everyone) + + self.assertEqual(Article.search([]), self.workspace_article + self.shared_article + self.private_article) + self.assertEqual(Article.search([('is_article_visible', '=', True)]), self.shared_article + self.private_article) + self.assertEqual(Article.search([('is_article_visible', '=', False)]), self.workspace_article) + self.assertEqual(Article.search([('is_article_visible', '!=', True)]), self.workspace_article) + self.assertEqual(Article.search([('is_article_visible', '!=', False)]), self.shared_article + self.private_article) + + private_article.set_is_article_visible_by_everyone(True) + + # When setting the `is_article_visible_by_everyone` field to `True`, the + # article should remains visible to everyone (with the exception of those + # having no access to the article). + + self.assertTrue(private_article.is_article_visible) + self.assertTrue(private_article.is_article_visible_by_everyone) + + self.assertEqual(Article.search([]), self.workspace_article + self.shared_article + self.private_article) + self.assertEqual(Article.search([('is_article_visible', '=', True)]), self.shared_article + self.private_article) + self.assertEqual(Article.search([('is_article_visible', '=', False)]), self.workspace_article) + self.assertEqual(Article.search([('is_article_visible', '!=', True)]), self.workspace_article) + self.assertEqual(Article.search([('is_article_visible', '!=', False)]), self.shared_article + self.private_article) + + @users('employee') + def test_is_shared_article_visible(self): + Article = self.env['knowledge.article'] + shared_article = self.shared_article.with_user(self.env.user) + + # For the shared articles, the articles should always be visible even + # if the article is not marked as visible to everyone and the user does + # not have explicit "read" or "write" permission on the article. + + self.assertEqual(shared_article.category, 'shared') + self.assertTrue(shared_article.user_has_access) + self.assertTrue(shared_article.is_article_visible) + self.assertFalse(shared_article.is_article_visible_by_everyone) + + self.assertEqual(Article.search([]), self.workspace_article + self.shared_article + self.private_article) + self.assertEqual(Article.search([('is_article_visible', '=', True)]), self.shared_article + self.private_article) + self.assertEqual(Article.search([('is_article_visible', '=', False)]), self.workspace_article) + self.assertEqual(Article.search([('is_article_visible', '!=', True)]), self.workspace_article) + self.assertEqual(Article.search([('is_article_visible', '!=', False)]), self.shared_article + self.private_article) + + shared_article.set_is_article_visible_by_everyone(True) + + self.assertTrue(shared_article.is_article_visible) + self.assertTrue(shared_article.is_article_visible_by_everyone) + + self.assertEqual(Article.search([]), self.workspace_article + self.shared_article + self.private_article) + self.assertEqual(Article.search([('is_article_visible', '=', True)]), self.shared_article + self.private_article) + self.assertEqual(Article.search([('is_article_visible', '=', False)]), self.workspace_article) + self.assertEqual(Article.search([('is_article_visible', '!=', True)]), self.workspace_article) + self.assertEqual(Article.search([('is_article_visible', '!=', False)]), self.shared_article + self.private_article) diff --git a/addons_extensions/knowledge/tests/test_knowledge_article_constraints.py b/addons_extensions/knowledge/tests/test_knowledge_article_constraints.py new file mode 100644 index 000000000..4022e6524 --- /dev/null +++ b/addons_extensions/knowledge/tests/test_knowledge_article_constraints.py @@ -0,0 +1,627 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from psycopg2 import IntegrityError + +from odoo import exceptions +from odoo.addons.knowledge.tests.common import KnowledgeCommon +from odoo.tests.common import tagged, users +from odoo.tools import mute_logger + + +@tagged('knowledge_internals') +class TestKnowledgeArticleConstraints(KnowledgeCommon): + """ This test suite has the responsibility to test the different constraints + defined on the `knowledge.article`, `knowledge.article.member` and + `knowledge.article.favorite` models. """ + + @classmethod + def setUpClass(cls): + """ Add some hierarchy to have mixed rights tests """ + super().setUpClass() + + # (i) means is_article_item = True + # - Employee Priv. seq=19 private none (employee-w+) + # - Playground seq=20 workspace w+ (admin-w+) + # - Shared seq=21 shared none (admin-w+,employee-r+,manager-r+) + # - Shared Child1 seq=0 " " (employee-w+) + # - Playground Item seq=22 worksapce w+ + # - Item Child seq=0 "" "" + cls.article_private_employee = cls.env['knowledge.article'].create( + {'article_member_ids': [ + (0, 0, {'partner_id': cls.partner_employee.id, + 'permission': 'write', + }), + ], + 'internal_permission': 'none', + 'name': 'Employee Priv.', + 'sequence': 19, + } + ) + cls.article_workspace = cls.env['knowledge.article'].create( + {'article_member_ids': [ + (0, 0, {'partner_id': cls.user_admin.partner_id.id, + 'permission': 'write'})], + 'internal_permission': 'write', + 'favorite_ids': [(0, 0, {'sequence': 1, + 'user_id': cls.user_admin.id, + }), + ], + 'name': 'Playground', + 'sequence': 20, + } + ) + cls.article_shared = cls.env['knowledge.article'].create( + {'article_member_ids': [ + (0, 0, {'partner_id': cls.partner_admin.id, + 'permission': 'write', + }), + (0, 0, {'partner_id': cls.partner_employee.id, + 'permission': 'read', + }), + (0, 0, {'partner_id': cls.partner_employee_manager.id, + 'permission': 'read', + }), + ], + 'internal_permission': 'none', + 'name': 'Shared', + 'sequence': 21, + } + ) + cls.shared_child = cls.env['knowledge.article'].create( + {'article_member_ids': [ + (0, 0, {'partner_id': cls.partner_employee.id, + 'permission': 'write', + }), + ], + 'internal_permission': False, + 'name': 'Shared Child1', + 'parent_id': cls.article_shared.id, + }, + ) + cls.items_parent = cls.env['knowledge.article'].create([ + {'internal_permission': 'write', + 'name': 'Parent of items', + 'parent_id': False, + 'sequence': 22, + } + ]) + cls.item_child = cls.env['knowledge.article'].create([{ + 'internal_permission': False, + 'name': 'Child Item', + 'parent_id': cls.items_parent.id, + 'is_article_item': True, + }]) + + @users('employee') + def test_article_acyclic_graph_move_to(self): + """ Check that the article hierarchy does not contain cycles using the move_to method. """ + article = self.article_workspace.with_env(self.env) + article_children = self.env['knowledge.article'].create([ + {'name': 'ChildNew1', + 'parent_id': article.id, + 'sequence': 3, + }, + {'name': 'ChildNew2', + 'parent_id': article.id, + 'sequence': 4, + } + ]) + + # move the parent article under one of its children should raise an exception + with self.assertRaises(exceptions.UserError, msg='The article hierarchy contains a cycle'): + article.move_to(parent_id=article_children[1].id) + + @users('employee') + def test_article_acyclic_graph_write_parent(self): + """ Check that the article hierarchy does not contain cycles when writing on parent_id. """ + article = self.article_workspace.with_env(self.env) + article_children = self.env['knowledge.article'].create([ + {'name': 'ChildNew1', + 'parent_id': article.id, + 'sequence': 3, + }, + {'name': 'ChildNew2', + 'parent_id': article.id, + 'sequence': 4, + } + ]) + + # move the parent article under one of its children should raise an exception + with self.assertRaises(exceptions.UserError, msg='The article hierarchy contains a cycle'): + article.write({ + 'parent_id': article_children[1].id + }) + + @mute_logger('odoo.addons.base.models.ir_model', 'odoo.addons.base.models.ir_rule') + @users('employee') + def test_article_create(self): + """ Testing the helper to create articles with right values. """ + article = self.article_workspace.with_env(self.env) + readonly_article = self.article_shared.with_env(self.env) + + _title = 'Fthagn, private' + private = self.env['knowledge.article'].create({ + 'article_member_ids': [ + (0, 0, {'partner_id': self.env.user.partner_id.id, + 'permission': 'write'}) + ], + 'body': f'{_title}
', + 'internal_permission': 'none', + 'name': _title, + }) + self.assertMembers(private, 'none', {self.env.user.partner_id: 'write'}) + self.assertEqual(private.category, 'private') + self.assertFalse(private.parent_id) + self.assertEqual(private.sequence, 23) + + _title = 'Fthagn, with parent (workspace)' + children = self.env['knowledge.article'].create([ + {'body': f'{_title}
', + 'name': _title, + 'parent_id': article.id, + } for idx in range(3) + ]) + for idx, child in enumerate(children): + self.assertMembers(child, False, {}) + self.assertEqual(child.category, 'workspace') + self.assertEqual(child.parent_id, article) + self.assertEqual(child.sequence, idx, 'Batch create should correctly set sequence') + + _title = 'Fthagn, with parent (private)' + child_private = self.env['knowledge.article'].create({ + 'body': f'{_title}
', + 'internal_permission': False, + 'name': _title, + 'parent_id': private.id, + }) + self.assertMembers(child, False, {}) + self.assertEqual(child_private.category, 'private') + self.assertEqual(child_private.parent_id, private) + self.assertEqual(child_private.sequence, 0) + + _title = 'Fthagn, but private under non private: cracboum' + with self.assertRaises(exceptions.AccessError): + _unwanted_child = self.env['knowledge.article'].create({ + 'article_member_ids': [ + (0, 0, {'partner_id': self.env.user.partner_id.id, + 'permission': 'write'}) + ], + 'body': f'{_title}
', + 'internal_permission': 'none', + 'name': _title, + 'parent_id': article.id, + }) + + _title = 'Fthagn, but with parent read only: cracboum' + with self.assertRaises(exceptions.AccessError): + _unallowed_child = self.env['knowledge.article'].create({ + 'body': f'{_title}
', + 'internal_permission': 'write', + 'name': _title, + 'parent_id': readonly_article.id, + }) + + @users('employee') + def test_article_move_to_shared_root(self): + """ Check constraints restricting moving as a shared root. + Only articles that are shared with at least 1 other member (not counting + internal permission) can be moved as a shared root""" + + # Add members with permission='none' to make sure they are not counted as members + workspace_article = self.env['knowledge.article'].sudo().create({ + 'article_member_ids': [ + (0, 0, {'partner_id': self.env.user.partner_id.id, 'permission': 'write'}), + (0, 0, {'partner_id': self.partner_employee2.id, 'permission': 'none'}), + (0, 0, {'partner_id': self.partner_employee_manager.id, 'permission': 'none'}), + ], + 'internal_permission': 'write', + 'name': 'Workspace Article without other read members', + }) + private_article = self.article_private_employee.with_env(self.env) + no_member_article = self.items_parent.with_env(self.env) + + with self.assertRaises(exceptions.ValidationError, + msg='Cannot move an article that is not shared with another member as a shared root'): + workspace_article.move_to(category='shared') + with self.assertRaises(exceptions.ValidationError, + msg='Cannot move a private article as a shared root'): + private_article.move_to(category='shared') + + with self.assertRaises(exceptions.ValidationError, + msg='Cannot move an article that has no member on it'): + no_member_article.move_to(category='shared') + + @mute_logger('odoo.addons.base.models.ir_rule') + @users('employee') + def test_article_parent_constraints_create(self): + """ Checking various article constraints linked to parents """ + article = self.article_workspace.with_env(self.env) + + # Add employee2 as read member + article.invite_members(self.partner_employee2, 'read') + + article_as2 = article.with_user(self.user_employee2) + self.assertFalse(article_as2.user_has_write_access) + self.assertTrue(article_as2.user_has_access) + + # Member should not be allowed to create an article under an article without "write" permission + with self.assertRaises(exceptions.AccessError): + self.env['knowledge.article'].with_user(self.user_employee2).create({ + 'internal_permission': 'write', + 'name': 'My Own', + 'parent_id': article_as2.id, + }) + + # Member should not be allowed to create a private article under a non-owned article + article_private = self._create_private_article('MyPrivate') + self.assertMembers(article_private, 'none', {self.partner_employee: 'write'}) + self.assertTrue(article_private.category, 'private') + self.assertTrue(article_private.user_has_write_access) + with self.assertRaises(exceptions.AccessError): + self.env['knowledge.article'].with_user(self.user_employee2).create({ + 'name': 'My Own Private', + 'parent_id': article_as2.id, + }) + + @mute_logger('odoo.addons.base.models.ir_rule') + @users('employee') + def test_article_parent_constraints_write(self): + """ Checking the article parent constraints. """ + article = self.article_workspace.with_env(self.env) + + # Add employee2 as read member + article.invite_members(self.partner_employee2, 'read') + + article_as2 = article.with_user(self.user_employee2) + self.assertFalse(article_as2.user_has_write_access) + self.assertTrue(article_as2.user_has_access) + + # Member should not be allowed to move an article under an article without "write" permission + article_user2 = self.env['knowledge.article'].with_user(self.user_employee2).create({ + 'internal_permission': 'write', + 'name': 'My Own', + }) + with self.assertRaises(exceptions.AccessError): + article_user2.write({'parent_id': article_as2.id}) + with self.assertRaises(exceptions.AccessError): + article_user2.move_to(parent_id=article_as2.id) + + # Member should be allowed to move an editable article under its current + # parent even if the parent is readonly, and specifying the parent id + # should not throw an error even if unnecessary. + article_child1 = self.env['knowledge.article'].create({ + 'internal_permission': 'write', + 'name': 'Ze Name', + 'parent_id': article.id, + 'sequence': 1, + }) + article_child2 = self.env['knowledge.article'].create({ + 'internal_permission': 'write', + 'name': 'Ze Name', + 'parent_id': article.id, + 'sequence': 2, + }) + article_child2.invite_members(self.partner_employee2, 'write') + article_child2_as2 = article_child2.with_user(self.user_employee2) + article_child2_as2.move_to(parent_id=article.id, before_article_id=article_child1.id) + self.assertEqual(article_child2_as2.sequence, 1) + self.assertEqual(article_child1.sequence, 2) + + @mute_logger('odoo.addons.base.models.ir_rule') + @users('employee') + def test_article_private_management(self): + """ Checking the article private management. """ + article_workspace = self.article_workspace.with_env(self.env) + + # Private-like article whose parent is not in private category is under workspace + article_private_u2 = self.env['knowledge.article'].sudo().create({ + 'article_member_ids': [(0, 0, {'partner_id': self.partner_employee2.id, 'permission': 'write'})], + 'internal_permission': 'none', + 'name': 'Private Child', + 'parent_id': article_workspace.id, + }).with_user(self.user_employee2) + self.assertEqual(article_private_u2.category, 'workspace') + self.assertTrue(article_private_u2.user_has_write_access) + + # Effectively private: other user cannot read it + article_private_u2_asuser = article_private_u2.with_user(self.env.user) + with self.assertRaises(exceptions.AccessError): + article_private_u2_asuser.body # should trigger ACLs + + # Private root article + article_private = self._create_private_article('MyPrivate').with_user(self.env.user) + self.assertTrue(article_private.category, 'private') + self.assertTrue(article_private.user_has_write_access) + + # Effectively private: other user cannot read it + article_private_asu2 = article_private.with_user(self.user_employee2) + with self.assertRaises(exceptions.AccessError): + article_private_asu2.body # should trigger ACLs + + # Move to workspace, makes it workspace + article_private.move_to(parent_id=article_workspace.id) + self.assertEqual(article_private.category, 'workspace') + + # Should be accessible by any user of the workspace since its permission is now inherited + article_private_asu2 = article_private.with_user(self.user_employee2) + article_private_asu2.body # should not trigger ACLs + self.assertFalse(article_private.internal_permission) + self.assertEqual(article_private.inherited_permission, 'write') + + @mute_logger('odoo.sql_db') + @users('employee') + def test_article_root_internal_permission(self): + """Check that the root article has internal permission set.""" + # defaulting to write permission if nothing is given + article = self.env['knowledge.article'].create({ + 'name': 'Article', + 'parent_id': False, + }) + self.assertEqual(article.category, 'workspace') + self.assertEqual(article.internal_permission, 'write') + + # ensure a member has write access before trying to remove global access + # and allow raising the IntegrityError (otherwise a ValidationError raises) + article.sudo().write({ + 'article_member_ids': [(0, 0, {'partner_id': self.env.user.partner_id.id, + 'permission': 'write'})], + }) + + with self.assertRaises(IntegrityError, msg='An internal permission should be set for root article'): + with self.cr.savepoint(): + article.write({'internal_permission': False}) + + article_child = self.env['knowledge.article'].create({ + 'name': 'Article', + 'parent_id': article.id, + }) + self.assertMembers(article_child, False, {}) + self.assertEqual(article_child.category, 'workspace') + self.assertEqual(article_child.root_article_id, article) + with self.assertRaises(IntegrityError, msg='An internal permission should be set for root article'): + with self.cr.savepoint(): + article_child.sudo().write({'parent_id': False}) + + @users('employee') + def test_article_should_have_at_least_one_writer(self): + """ Check that an article has at least one writer.""" + with self.assertRaises(exceptions.ValidationError, msg='Article should have at least one writer'): + self.env['knowledge.article'].create({ + 'internal_permission': 'none', + 'name': 'Article', + }) + with self.assertRaises(exceptions.ValidationError, msg='Article should have at least one writer'): + self.env['knowledge.article'].create({ + 'internal_permission': 'read', + 'name': 'Article', + }) + + article_private = self._create_private_article('MyPrivate') + self.assertMembers(article_private, 'none', {self.partner_employee: 'write'}) + self.assertEqual(article_private.category, 'private') + + # take membership as sudo to really have access to unlink feature + membership_sudo = article_private.sudo().article_member_ids + # cannot transform last writer into rejected + with self.assertRaises(exceptions.ValidationError, msg='Cannot remove the last writer on an article'): + article_private.sudo().write({ + 'article_member_ids': [(1, membership_sudo.id, {'permission': 'none'})] + }) + with self.assertRaises(exceptions.ValidationError, msg='Cannot remove the last writer on an article'): + article_private.sudo().write({ + 'article_member_ids': [(1, membership_sudo.id, {'permission': 'read'})] + }) + with self.assertRaises(exceptions.ValidationError, msg='Cannot remove the last writer on an article'): + article_private._add_members(membership_sudo.partner_id, 'none') + # cannot remove last writer + with self.assertRaises(exceptions.ValidationError, msg='Cannot remove the last writer on an article'): + membership_sudo.unlink() + with self.assertRaises(exceptions.ValidationError, msg='Cannot remove the last writer on an article'): + article_private.sudo().write({ + 'article_member_ids': self.env['knowledge.article.member'] + }) + with self.assertRaises(exceptions.ValidationError, msg='Cannot remove the last writer on an article'): + article_private.sudo().write({ + 'article_member_ids': [(2, membership_sudo.id)] + }) + # Special Case: can leave own private article via _remove_member: will archive the article. + article_private.sudo()._remove_member(membership_sudo) + self.assertFalse(article_private.active) + self.assertMembers(article_private, 'none', {self.env.user.partner_id: 'write'}) + + # moving the article to private will remove the second member + # but should not trigger an error since we also add 'employee' as a write member + article_workspace = self.article_workspace.with_env(self.env) + article_workspace.move_to(category='private') + self.assertEqual(article_workspace.category, 'private') + self.assertTrue(article_workspace._has_write_member()) + + @mute_logger('odoo.sql_db') + @users('employee') + def test_article_trashed_should_be_archived(self): + """ Ensure that a trashed article is archived.""" + article = self.env['knowledge.article'].create({ + 'name': 'Article', + 'parent_id': False, + }) + + with self.assertRaises(IntegrityError, msg='Trashed articles must be archived.'): + with self.cr.savepoint(): + article.write({'to_delete': True}) + + article.write({ + 'to_delete': True, + 'active': False, + }) + self.assertTrue(article.to_delete) + self.assertFalse(article.active) + + with self.assertRaises(IntegrityError, msg='Trashed articles must be archived.'): + with self.cr.savepoint(): + article.write({'active': True}) + + article.write({ + 'to_delete': False, + 'active': True, + }) + self.assertTrue(article.active) + self.assertFalse(article.to_delete) + + @mute_logger('odoo.sql_db') + @users('employee') + def test_favourite_uniqueness(self): + """ Check there is at most one 'knowledge.article.favourite' entry per + article and user. """ + article = self.env['knowledge.article'].create( + {'internal_permission': 'write', + 'name': 'Article'} + ) + self.assertFalse(article.is_user_favorite) + article.action_toggle_favorite() + self.assertTrue(article.is_user_favorite) + with self.assertRaises(IntegrityError, + msg='Multiple favorite entries are not accepted'): + article.write({'favorite_ids': [(0, 0, {'user_id': self.env.user.id})]}) + self.assertTrue(article.is_user_favorite) + + @mute_logger('odoo.sql_db') + @users('employee') + def test_member_uniqueness(self): + """Check that there are no duplicated members in the member list. """ + article = self.env['knowledge.article'].create({ + 'internal_permission': 'write', + 'name': 'Article', + }) + article.sudo().write({ + 'article_member_ids': [(0, 0, {'partner_id': self.env.user.partner_id.id, + 'permission': 'write'})] + }) + self.assertEqual(len(self.env['knowledge.article.member'].sudo().search([('article_id', '=', article.id)])), 1) + + # adding a duplicate + with self.assertRaises(IntegrityError, + msg='Members should be unique (article_id/partner_id)'): + article.sudo().write({ + 'article_member_ids': [(0, 0, {'partner_id': self.env.user.partner_id.id, + 'permission': 'write'})] + }) + self.assertEqual(len(self.env['knowledge.article.member'].sudo().search([('article_id', '=', article.id)])), 1) + + # trying with tool method + article.invite_members(self.env.user.partner_id, 'write') + self.assertEqual(len(self.env['knowledge.article.member'].sudo().search([('article_id', '=', article.id)])), 1) + + # creating duplicates + with self.assertRaises(IntegrityError, + msg='Members should be unique (article_id/partner_id)'): + article.invite_members(self.partner_employee2 + self.partner_employee2, 'write') + self.assertEqual(len(self.env['knowledge.article.member'].sudo().search([('article_id', '=', article.id)])), 1) + + with self.assertRaises(IntegrityError, + msg='Members should be unique (article_id/partner_id)'): + article.sudo().write({ + 'article_member_ids': [(0, 0, {'partner_id': self.partner_admin.id, + 'permission': 'write'}), + (0, 0, {'partner_id': self.partner_admin.id, + 'permission': 'write'}) + ], + }) + self.assertEqual(len(self.env['knowledge.article.member'].sudo().search([('article_id', '=', article.id)])), 1) + + @mute_logger('odoo.sql_db') + @users('employee') + def test_article_item_create(self): + with self.assertRaises(IntegrityError, msg='Cannot create an article item without parent'): + self.env['knowledge.article'].create([{ + 'internal_permission': False, + 'name': 'Orphan Item', + 'parent_id': False, + 'is_article_item': True, + }]) + + # Checking children. + self.assertEqual(len(self.items_parent.child_ids), 1) + self.assertTrue(self.items_parent.has_item_children) + + # Can create an article item under a parent of items + self.env['knowledge.article'].create([{ + 'internal_permission': False, + 'name': 'Child Item 2', + 'parent_id': self.items_parent.id, + 'is_article_item': True, + }]) + self.assertEqual(len(self.items_parent.child_ids), 2) + self.assertTrue(self.items_parent.has_item_children) + + # Can create a normal article under a parent of items + self.env['knowledge.article'].create([{ + 'internal_permission': False, + 'name': 'Child Item 3', + 'parent_id': self.items_parent.id, + 'is_article_item': False, + }]) + + # Can create an article item under parent with no child. A parent item can be an item itself. + self.assertTrue(len(self.item_child.child_ids) == 0) + self.env['knowledge.article'].create([{ + 'internal_permission': False, + 'name': 'grand child item', + 'parent_id': self.item_child.id, + 'is_article_item': True, + }]) + self.assertEqual(len(self.item_child.child_ids), 1) + self.assertTrue(self.item_child.has_item_children) + + @mute_logger('odoo.sql_db') + @users('employee') + def test_article_item_write(self): + # Can move an article item under any parent + # - Under an item parent: it stays an article item + items_parent_2 = self.env['knowledge.article'].create([{ + 'internal_permission': 'write', + 'name': 'item parent 2', + 'parent_id': False, + }]) + self.env['knowledge.article'].create([{ + 'internal_permission': False, + 'name': 'item child 2', + 'parent_id': items_parent_2.id, + 'is_article_item': True, + }]) + self.item_child.move_to(items_parent_2.id) + self.assertTrue(self.item_child.is_article_item) + + # - Under a parent that is not an item parent and has no children : + # it stays an article item and the parent becomes an item parent + self.assertTrue(len(self.shared_child.child_ids) == 0) + self.item_child.move_to(self.shared_child.id) + self.assertTrue(self.item_child.is_article_item) + self.assertEqual(len(self.shared_child.child_ids), 1) + self.assertTrue(self.shared_child.has_item_children) + + # - Under a parent that is not an item parent and already has children : + # it stays an article item - both types can co-exist. + self.env['knowledge.article'].create([{ + 'internal_permission': False, + 'name': 'workspace child', + 'parent_id': self.article_workspace.id, + 'is_article_item': False, + }]) + self.assertEqual(len(self.article_workspace.child_ids), 1) + self.assertTrue(self.article_workspace.has_article_children) + + self.item_child.move_to(self.article_workspace.id) + + self.assertTrue(self.item_child.is_article_item) + self.assertEqual(len(self.article_workspace.child_ids), 2) + self.assertTrue(self.article_workspace.has_article_children) + + with self.assertRaises(IntegrityError, msg='An article item must have a parent'): + self.item_child.write({'parent_id': False}) + + # Can move a normal article under an item parent: the article stays a normal article + self.item_child.write({'is_article_item': False}) + self.assertFalse(self.item_child.is_article_item) + self.item_child.move_to(items_parent_2.id) + self.assertFalse(self.item_child.is_article_item) diff --git a/addons_extensions/knowledge/tests/test_knowledge_article_full_text_search.py b/addons_extensions/knowledge/tests/test_knowledge_article_full_text_search.py new file mode 100644 index 000000000..588d745ca --- /dev/null +++ b/addons_extensions/knowledge/tests/test_knowledge_article_full_text_search.py @@ -0,0 +1,389 @@ +from markupsafe import Markup + +from odoo.tests.common import users +from odoo.tools import mute_logger + +from odoo.addons.knowledge.tests.common import KnowledgeCommon + + +class TestKnowledgeArticleFullTextSearch(KnowledgeCommon): + """ Test suite dedicated to the search feature of the command palette. + This test suite should ensure that: + 1. The search feature enables users to find an article containing the + given search terms or having the given title name. + 2. The search feature only returns articles the user has access to. + 3. The search feature filters out hidden articles from the results + unless the "hidden_mode" option is enable. + 4. The search feature ranks first: + - Articles matching with the title and the body + - Then, articles matching with the title only + - Then, articles matching with the body only """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Articles: + Article = cls.env['knowledge.article'] + with mute_logger('odoo.models.unlink'): + Article.search([]).unlink() + + # Workspace section: + cls.workspace_article_visible = Article.create({ + 'name': "10 unknown facts about Pim's", + 'internal_permission': 'write', + 'is_article_visible_by_everyone': True, + 'favorite_ids': [(0, 0, { + 'user_id': cls.user_admin.id + })], + 'body': Markup("Pim's are circular in shape, making them easier to eat.
") + }) + cls.workspace_child_article_visible = Article.create({ + 'name': 'Ingredients', + 'internal_permission': 'write', + 'is_article_visible_by_everyone': True, + 'favorite_ids': [(0, 0, { + 'user_id': cls.user_employee.id + })], + 'parent_id': cls.workspace_article_visible.id, + 'body': Markup("Most Pim's are made with biscuit, marmalade and chocolate.
") + }) + cls.workspace_article_hidden = Article.create({ + 'name': 'HR', + 'internal_permission': 'write', + 'body': Markup('The company has 50 amazing employees
') + }) + cls.workspace_child_article_hidden = Article.create({ + 'name': 'Recruitment', + 'internal_permission': 'write', + 'parent_id': cls.workspace_article_hidden.id, + 'body': Markup("That's amazing! We hired 3 new employees this semester
") + }) + + # Shared section: + cls.shared_article = Article.create({ + 'name': 'TODO list', + 'internal_permission': 'none', + 'article_member_ids': [ + (0, 0, { + 'partner_id': cls.user_admin.partner_id.id, + 'permission': 'write', + }), + (0, 0, { + 'partner_id': cls.user_employee.partner_id.id, + 'permission': 'write', + }) + ], + 'body': Markup("Purchase Pim's
") + }) + + # Private section: + cls.private_article_admin = Article.create({ + 'name': "My favorite Pim's flavors", + 'internal_permission': 'none', + 'article_member_ids': [(0, 0, { + 'partner_id': cls.user_admin.partner_id.id, + 'permission': 'write', + })], + 'body': Markup('Orange, Raspberry, etc.
') + }) + cls.private_article_user = Article.create({ + 'name': "Secret Luc's birthday party", + 'internal_permission': 'none', + 'article_member_ids': [(0, 0, { + 'partner_id': cls.user_employee.partner_id.id, + 'permission': 'write', + })], + 'body': Markup("Don't forget to bring some Pim's!
") + }) + + @users('admin') + def test_get_user_sorted_articles_admin(self): + """ Check that the administrator can find the articles he has access to. """ + test_dataset = [ + # search terms, hidden mode, expected results + ('', False, [{ + 'id': self.workspace_article_visible.id, + 'icon': False, + 'name': "10 unknown facts about Pim's", + 'is_user_favorite': True, + 'root_article_id': (self.workspace_article_visible.id, "📄 10 unknown facts about Pim's") + }]), + ("10 unknown facts about Pim's", False, [{ + 'id': self.workspace_article_visible.id, + 'icon': False, + 'name': "10 unknown facts about Pim's", + 'is_user_favorite': True, + 'root_article_id': (self.workspace_article_visible.id, "📄 10 unknown facts about Pim's") + }]), + ('shape', False, [{ + 'id': self.workspace_article_visible.id, + 'icon': False, + 'name': "10 unknown facts about Pim's", + 'headline': 'circular in shape, making them easier', + 'is_user_favorite': True, + 'root_article_id': (self.workspace_article_visible.id, "📄 10 unknown facts about Pim's") + }]), + ('Ingredients', False, [{ + 'id': self.workspace_child_article_visible.id, + 'icon': False, + 'name': 'Ingredients', + 'is_user_favorite': False, + 'root_article_id': (self.workspace_article_visible.id, "📄 10 unknown facts about Pim's") + }]), + ('biscuit marmalade chocolate', False, [{ + 'id': self.workspace_child_article_visible.id, + 'icon': False, + 'name': 'Ingredients', + 'headline': "Most Pim's are made with biscuit, marmalade and chocolate", + 'is_user_favorite': False, + 'root_article_id': (self.workspace_article_visible.id, "📄 10 unknown facts about Pim's") + }]), + ('HR', False, []), + ('HR', True, [{ + 'id': self.workspace_article_hidden.id, + 'icon': False, + 'name': 'HR', + 'is_user_favorite': False, + 'root_article_id': (self.workspace_article_hidden.id, '📄 HR') + }]), + ('company', False, []), + ('company', True, [{ + 'id': self.workspace_article_hidden.id, + 'icon': False, + 'name': 'HR', + 'headline': 'company has 50 amazing employees', + 'is_user_favorite': False, + 'root_article_id': (self.workspace_article_hidden.id, '📄 HR') + }]), + ('Recruitment', False, []), + ('Recruitment', True, [{ + 'id': self.workspace_child_article_hidden.id, + 'icon': False, + 'name': 'Recruitment', + 'is_user_favorite': False, + 'root_article_id': (self.workspace_article_hidden.id, '📄 HR') + }]), + ('semester', False, []), + ('semester', True, [{ + 'id': self.workspace_child_article_hidden.id, + 'icon': False, + 'name': 'Recruitment', + 'headline': "That's amazing! We hired 3 new employees this semester", + 'is_user_favorite': False, + 'root_article_id': (self.workspace_article_hidden.id, '📄 HR') + }]), + ('TODO list', False, [{ + 'id': self.shared_article.id, + 'icon': False, + 'name': 'TODO list', + 'is_user_favorite': False, + 'root_article_id': (self.shared_article.id, '📄 TODO list') + }]), + ("Purchase Pim's", False, [{ + 'id': self.shared_article.id, + 'icon': False, + 'name': 'TODO list', + 'headline': "Purchase Pim's", + 'is_user_favorite': False, + 'root_article_id': (self.shared_article.id, '📄 TODO list') + }]), + ("My favorite Pim's flavors", False, [{ + 'id': self.private_article_admin.id, + 'icon': False, + 'name': "My favorite Pim's flavors", + 'is_user_favorite': False, + 'root_article_id': (self.private_article_admin.id, "📄 My favorite Pim's flavors") + }]), + ('Orange', False, [{ + 'id': self.private_article_admin.id, + 'icon': False, + 'name': "My favorite Pim's flavors", + 'headline': 'Orange, Raspberry', + 'is_user_favorite': False, + 'root_article_id': (self.private_article_admin.id, "📄 My favorite Pim's flavors") + }]), + ("Secret Luc's birthday party", False, []), + ('forget', False, []) + ] + + Article = self.env['knowledge.article'] + for search_term, hidden_mode, expected_result in test_dataset: + with self.subTest(search_term=search_term): + self.assertEqual( + Article.get_user_sorted_articles(search_term, hidden_mode=hidden_mode), + expected_result, + msg=f'search_term="{search_term}"') + + @users('employee') + def test_get_user_sorted_articles_user(self): + """ Check that the user can find the articles he has access to. """ + test_dataset = [ + # search terms, hidden_mode, expected results + ('', False, [{ + 'id': self.workspace_child_article_visible.id, + 'icon': False, + 'name': 'Ingredients', + 'is_user_favorite': True, + 'root_article_id': (self.workspace_article_visible.id, "📄 10 unknown facts about Pim's") + }]), + ("10 unknown facts about Pim's", False, [{ + 'id': self.workspace_article_visible.id, + 'icon': False, + 'name': "10 unknown facts about Pim's", + 'is_user_favorite': False, + 'root_article_id': (self.workspace_article_visible.id, "📄 10 unknown facts about Pim's") + }]), + ('shape', False, [{ + 'id': self.workspace_article_visible.id, + 'icon': False, + 'name': "10 unknown facts about Pim's", + 'headline': 'circular in shape, making them easier', + 'is_user_favorite': False, + 'root_article_id': (self.workspace_article_visible.id, "📄 10 unknown facts about Pim's") + }]), + ('Ingredients', False, [{ + 'id': self.workspace_child_article_visible.id, + 'icon': False, + 'name': 'Ingredients', + 'is_user_favorite': True, + 'root_article_id': (self.workspace_article_visible.id, "📄 10 unknown facts about Pim's") + }]), + ('biscuit marmalade chocolate', False, [{ + 'id': self.workspace_child_article_visible.id, + 'icon': False, + 'name': 'Ingredients', + 'headline': "Most Pim's are made with biscuit, marmalade and chocolate", + 'is_user_favorite': True, + 'root_article_id': (self.workspace_article_visible.id, "📄 10 unknown facts about Pim's") + }]), + ('HR', False, []), + ('HR', True, [{ + 'id': self.workspace_article_hidden.id, + 'icon': False, + 'name': 'HR', + 'is_user_favorite': False, + 'root_article_id': (self.workspace_article_hidden.id, '📄 HR') + }]), + ('company', False, []), + ('company', True, [{ + 'id': self.workspace_article_hidden.id, + 'icon': False, + 'name': 'HR', + 'headline': 'company has 50 amazing employees', + 'is_user_favorite': False, + 'root_article_id': (self.workspace_article_hidden.id, '📄 HR') + }]), + ('Recruitment', False, []), + ('Recruitment', True, [{ + 'id': self.workspace_child_article_hidden.id, + 'icon': False, + 'name': 'Recruitment', + 'is_user_favorite': False, + 'root_article_id': (self.workspace_article_hidden.id, '📄 HR') + }]), + ('semester', False, []), + ('semester', True, [{ + 'id': self.workspace_child_article_hidden.id, + 'icon': False, + 'name': 'Recruitment', + 'headline': "That's amazing! We hired 3 new employees this semester", + 'is_user_favorite': False, + 'root_article_id': (self.workspace_article_hidden.id, '📄 HR') + }]), + ('TODO list', False, [{ + 'id': self.shared_article.id, + 'icon': False, + 'name': 'TODO list', + 'is_user_favorite': False, + 'root_article_id': (self.shared_article.id, '📄 TODO list') + }]), + ("Purchase Pim's", False, [{ + 'id': self.shared_article.id, + 'icon': False, + 'name': 'TODO list', + 'headline': "Purchase Pim's", + 'is_user_favorite': False, + 'root_article_id': (self.shared_article.id, '📄 TODO list') + }]), + ("My favorite Pim's flavors", False, []), + ('Gift', False, []), + ("Secret Luc's birthday party", False, [{ + 'id': self.private_article_user.id, + 'icon': False, + 'name': "Secret Luc's birthday party", + 'is_user_favorite': False, + 'root_article_id': (self.private_article_user.id, "📄 Secret Luc's birthday party") + }]), + ('forget', False, [{ + 'id': self.private_article_user.id, + 'icon': False, + 'name': "Secret Luc's birthday party", + 'headline': 'forget to bring some', + 'is_user_favorite': False, + 'root_article_id': (self.private_article_user.id, "📄 Secret Luc's birthday party") + }]) + ] + + Article = self.env['knowledge.article'] + for search_term, hidden_mode, expected_result in test_dataset: + with self.subTest(search_term=search_term): + self.assertEqual( + Article.get_user_sorted_articles(search_term, hidden_mode=hidden_mode), + expected_result, + msg=f'search_term="{search_term}"') + + @users('admin') + def test_get_user_sorted_ordering(self): + """ Check that the search method return articles in the following order: + 1. The articles matching with the title and the body + 2. The article matching with the title only + 3. The article matching with the body only + Within each group, the search method should rank the articles based + on the frequency and the co-occurrence of the search terms in the + article body (see: `ts_rank_cd`). + """ + Article = self.env['knowledge.article'] + self.assertEqual(Article.get_user_sorted_articles("Pim's", hidden_mode=False), [{ + 'id': self.workspace_article_visible.id, + 'icon': False, + 'name': "10 unknown facts about Pim's", + 'headline': "Pim's are circular in shape, making them easier", + 'is_user_favorite': True, + 'root_article_id': (self.workspace_article_visible.id, "📄 10 unknown facts about Pim's") + }, { + 'id': self.private_article_admin.id, + 'icon': False, + 'name': "My favorite Pim's flavors", + 'is_user_favorite': False, + 'root_article_id': (self.private_article_admin.id, "📄 My favorite Pim's flavors") + }, { + 'id': self.shared_article.id, + 'icon': False, + 'name': 'TODO list', + 'headline': "Purchase Pim's", + 'is_user_favorite': False, + 'root_article_id': (self.shared_article.id, '📄 TODO list') + }, { + 'id': self.workspace_child_article_visible.id, + 'icon': False, + 'name': 'Ingredients', + 'headline': "Most Pim's are made with biscuit, marmalade and chocolate", + 'is_user_favorite': False, + 'root_article_id': (self.workspace_article_visible.id, "📄 10 unknown facts about Pim's") + }]) + + self.assertEqual(Article.get_user_sorted_articles('amazing employees', hidden_mode=True), [{ + 'id': self.workspace_article_hidden.id, + 'icon': False, + 'name': 'HR', + 'headline': 'company has 50 amazing employees', 'is_user_favorite': False, + 'root_article_id': (self.workspace_article_hidden.id, '📄 HR') + }, { + 'id': self.workspace_child_article_hidden.id, + 'icon': False, + 'name': 'Recruitment', + 'headline': "That's amazing! We hired 3 new employees this semester", + 'is_user_favorite': False, + 'root_article_id': (self.workspace_article_hidden.id, '📄 HR') + }]) diff --git a/addons_extensions/knowledge/tests/test_knowledge_article_internals.py b/addons_extensions/knowledge/tests/test_knowledge_article_internals.py new file mode 100644 index 000000000..18a8537d9 --- /dev/null +++ b/addons_extensions/knowledge/tests/test_knowledge_article_internals.py @@ -0,0 +1,311 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime, timedelta +from freezegun import freeze_time + +from odoo import exceptions +from odoo.addons.knowledge.tests.common import KnowledgeCommonWData +from odoo.tests.common import tagged, users +from odoo.tools import mute_logger + + +@tagged('knowledge_internals') +class TestKnowledgeArticleFields(KnowledgeCommonWData): + """ Test fields and their management. """ + + @users('employee') + def test_favorites(self): + """ Testing the API for toggling favorites. """ + playground_articles = (self.article_workspace + self.workspace_children).with_env(self.env) + self.assertEqual(playground_articles.mapped('is_user_favorite'), [False, False, False]) + + first_playground_article = playground_articles[0] + self.assertFalse(first_playground_article.is_user_favorite) + + first_playground_article.write({'favorite_ids': [(0, 0, {'user_id': self.env.uid})]}) + self.assertEqual(playground_articles.mapped('is_user_favorite'), [True, False, False]) + self.assertEqual(playground_articles.mapped('user_favorite_sequence'), [1, -1, -1]) + favorites = self.env['knowledge.article.favorite'].sudo().search([('user_id', '=', self.env.uid)]) + self.assertEqual(favorites.article_id, first_playground_article) + self.assertEqual(favorites.sequence, 1) + + playground_articles[1].action_toggle_favorite() + self.assertEqual(playground_articles.mapped('is_user_favorite'), [True, True, False]) + self.assertEqual(playground_articles.mapped('user_favorite_sequence'), [1, 2, -1]) + favorites = self.env['knowledge.article.favorite'].sudo().search([('user_id', '=', self.env.uid)]) + self.assertEqual(favorites.article_id, playground_articles[0:2]) + self.assertEqual(favorites.mapped('sequence'), [1, 2]) + + playground_articles[2].with_user(self.user_employee2).action_toggle_favorite() + favorites = self.env['knowledge.article.favorite'].sudo().search([('user_id', '=', self.user_employee2.id)]) + self.assertEqual(favorites.article_id, playground_articles[2]) + self.assertEqual(favorites.sequence, 1, 'Favorite: should not be impacted by other people sequence') + + @users('admin') # test as admin as this is a technical sync done as sudo + def test_favorites_active_sync(self): + """ Make sure the 'is_article_active' is synchronized with the article 'active' field. """ + + article_favorites = self.env['knowledge.article.favorite'].create([{ + 'user_id': user_id, + 'article_id': self.article_workspace.id, + } for user_id in (self.user_employee | self.user_employee2).ids]) + + self.assertEqual(len(article_favorites), 2) + self.assertTrue(article_favorites[0].is_article_active) + self.assertTrue(article_favorites[1].is_article_active) + + self.article_workspace.action_archive() + self.assertFalse(article_favorites[0].is_article_active) + self.assertFalse(article_favorites[1].is_article_active) + + self.article_workspace.action_unarchive() + self.assertTrue(article_favorites[0].is_article_active) + self.assertTrue(article_favorites[1].is_article_active) + + @users('employee') + def test_fields_edition(self): + _reference_dt = datetime(2022, 5, 31, 10, 0, 0) + body_values = [False, '', 'MyBody
'] + + for index, body in enumerate(body_values): + self.patch(self.env.cr, 'now', lambda: _reference_dt) + with freeze_time(_reference_dt): + article = self.env['knowledge.article'].create({ + 'body': body, + 'internal_permission': 'write', + 'name': 'MyArticle,' + }) + self.assertEqual(article.last_edition_uid, self.env.user) + self.assertEqual(article.last_edition_date, _reference_dt) + self.assertFalse(article.html_field_history) + + self.patch(self.env.cr, 'now', lambda: _reference_dt + timedelta(days=1)) + + # fields that does not change content + with freeze_time(_reference_dt + timedelta(days=1)): + article.with_user(self.user_employee2).write({ + 'name': 'NoContentEdition' + }) + self.assertEqual(article.last_edition_uid, self.env.user) + self.assertEqual(article.last_edition_date, _reference_dt) + self.assertFalse(article.html_field_history, + 'Body did not change: no history should have been created') + + # fields that change content + body_changes_count = 0 + with freeze_time(_reference_dt + timedelta(days=1)): + body_value = body_values[(index + 1) if index < (len(body_values)-1) else 0] + article.with_user(self.user_employee2).write({'body': body_value}) + body_changes_count += 1 if body_value != body and (bool(body_value) or bool(body)) else 0 + # the with_user() below is necessary for the test to succeed, + # and that's kind of a bad smell... + article.with_user(self.user_employee2).flush_model() + self.assertEqual(article.last_edition_uid, self.user_employee2) + self.assertEqual(article.last_edition_date, _reference_dt + timedelta(days=1)) + if body_changes_count: + self.assertEqual(len(article.html_field_history["body"]), body_changes_count) + else: + self.assertFalse(article.html_field_history) + + +@tagged('knowledge_internals') +class TestKnowledgeArticleInternals(KnowledgeCommonWData): + """ Testing model/ORM overrides """ + + def test_display_name(self): + """ Test our custom display_name / name_search / name_create. """ + + KnowledgeArticle = self.env['knowledge.article'] + icon_placeholder = KnowledgeArticle._get_no_icon_placeholder() + + KnowledgeArticle.search([]).unlink() # remove other articles to ease testing + [article_no_icon, article_icon] = KnowledgeArticle.create([{ + 'name': 'Article Without Icon', + }, { + 'icon': '🚀', + 'name': 'Article With Icon' + }]) + + self.assertEqual( + article_no_icon.display_name, + '%s Article Without Icon' % icon_placeholder + ) + + self.assertEqual( + article_icon.display_name, + '🚀 Article With Icon' + ) + + # test the 'ilike' operator + for search_input, expected_articles in zip( + ['Article With', + '🚀 Article With', + '%s Article With' % icon_placeholder, + '⭐ Test'], + [article_icon + article_no_icon, + article_icon, + article_no_icon, + KnowledgeArticle] + ): + self.assertEqual( + KnowledgeArticle.name_search(name=search_input, operator='ilike'), + [(rec.id, rec.display_name) for rec in expected_articles], + "Not matching for input '%s'" % search_input, + ) + + # test the '=' operator + for search_input, expected_articles in zip( + ['Article Without Icon', + '%s Article Without Icon' % icon_placeholder, + 'Article With Icon', + '🚀 Article With Icon', + '🚀 Article With', + '⭐ Test'], + [article_no_icon, + article_no_icon, + article_icon, + article_icon, + KnowledgeArticle, + KnowledgeArticle] + ): + self.assertEqual( + KnowledgeArticle.name_search(name=search_input, operator='='), + [(rec.id, rec.display_name) for rec in expected_articles], + "Not matching for input '%s'" % search_input, + ) + + # now test the name_create custom implementation + for create_input, (expected_name, expected_icon) in zip( + ['a', + 'Article Without Icon', + '%s Article With Icon' % icon_placeholder, + '🚀 Article With Icon'], + [('a', False), + ('Article Without Icon', False), + ('Article With Icon', icon_placeholder), + ('Article With Icon', '🚀')] + ): + # result of name_create is a display_name + new_article = KnowledgeArticle.browse(KnowledgeArticle.name_create(create_input)[0]) + self.assertEqual(new_article.name, expected_name) + self.assertEqual(new_article.icon, expected_icon) + + +@tagged('knowledge_internals') +class TestKnowledgeArticleUtilities(KnowledgeCommonWData): + """ Test data oriented utilities and tools for articles. """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env['knowledge.article'].create({ + 'name': 'Child Workspace Item', + 'is_article_item': True, + 'parent_id': cls.article_workspace.id + }) + + @users('employee') + def test_article_get_valid_parent_options(self): + child_writable_article = self.workspace_children[1].with_env(self.env) + res = child_writable_article.get_valid_parent_options(search_term="") + self.assertEqual( + sorted(item['id'] for item in res), + sorted( + (self.article_workspace + self.workspace_children[0] + self.article_shared + self.shared_children).ids + ), + 'Should contain: brother, parent and other accessible articles (shared section)' + ) + + root_writable_article = self.article_workspace.with_env(self.env) + res = root_writable_article.get_valid_parent_options(search_term="") + self.assertEqual( + sorted(item['id'] for item in res), + sorted( + (self.article_shared + self.shared_children).ids + ), + 'Should contain: none of descendants, so only other accessible articles (shared section)' + ) + + root_writable_article = self.article_workspace.with_env(self.env) + res = root_writable_article.get_valid_parent_options(search_term="child") + self.assertEqual( + sorted(item['id'] for item in res), + sorted( + (self.shared_children).ids + ), + 'Should contain: none of descendants, so only other accessible articles (shared section), filtered by search term' + ) + + self.assertEqual( + child_writable_article.get_valid_parent_options(search_term='Item'), + [], + 'Should contain: nothing as the searched article is an Article Item' + ) + + @users('employee') + def test_article_get_ancestor_ids(self): + # Using ids from method docstring for easy matching. + # Order doesn't matter for this line + article_2, article_6 = self.env['knowledge.article'].create([ + {'name': 'Article 2'}, + {'name': 'Article 6'}] + ) + article_4 = self.env['knowledge.article'].create({'name': 'Article 4', 'parent_id': article_2.id}) + article_8 = self.env['knowledge.article'].create({'name': 'Article 8', 'parent_id': article_4.id}) + article_11 = self.env['knowledge.article'].create({'name': 'Article 11', 'parent_id': article_6.id}) + + # Use lists to assert correct order (_get_ancestor_ids returns an OrderedSet) + self.assertEqual(list(article_8._get_ancestor_ids()), [article_4.id, article_2.id]) + self.assertEqual(list((article_8 | article_4)._get_ancestor_ids()), [article_4.id, article_2.id]) + self.assertEqual(list((article_8 | article_11)._get_ancestor_ids()), [article_4.id, article_2.id, article_6.id]) + + +@tagged('knowledge_internals', 'knowledge_management') +class TestKnowledgeCommonWDataInitialValue(KnowledgeCommonWData): + """ Test initial values or our test data once so that other tests do not have + to do it. """ + + def test_initial_values(self): + """ Ensure all tests have the same basis (global values computed as root) """ + # root + article_workspace = self.article_workspace + self.assertTrue(article_workspace.category, 'workspace') + self.assertEqual(article_workspace.sequence, 999) + article_shared = self.article_shared + self.assertTrue(article_shared.category, 'shared') + self.assertTrue(article_shared.sequence, 998) + + # workspace children + workspace_children = article_workspace.child_ids + self.assertEqual( + workspace_children.mapped('inherited_permission'), + ['write', 'write'] + ) + self.assertEqual(workspace_children.inherited_permission_parent_id, article_workspace) + self.assertEqual( + workspace_children.mapped('internal_permission'), + [False, False] + ) + self.assertEqual(workspace_children.root_article_id, article_workspace) + # articles are not ordered by sequence. + # Make explicit check that first created has sequence 0, second has 1, etc. + self.assertEqual(self.workspace_children[0].sequence, 0) + self.assertEqual(self.workspace_children[1].sequence, 1) + + @mute_logger('odoo.addons.base.models.ir_rule') + @users('employee') + def test_initial_values_as_employee(self): + """ Ensure all tests have the same basis (user specific computed as + employee for acl-dependent tests) """ + article_workspace = self.article_workspace.with_env(self.env) + self.assertTrue(article_workspace.user_has_access) + self.assertTrue(article_workspace.user_has_write_access) + + article_shared = self.article_shared.with_env(self.env) + self.assertTrue(article_shared.user_has_access) + self.assertFalse(article_shared.user_has_write_access) + + article_private = self.article_private_manager.with_env(self.env) + with self.assertRaises(exceptions.AccessError): + self.assertFalse(article_private.body) diff --git a/addons_extensions/knowledge/tests/test_knowledge_article_permissions.py b/addons_extensions/knowledge/tests/test_knowledge_article_permissions.py new file mode 100644 index 000000000..ad569cb0f --- /dev/null +++ b/addons_extensions/knowledge/tests/test_knowledge_article_permissions.py @@ -0,0 +1,768 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import exceptions +from odoo.addons.knowledge.tests.common import KnowledgeArticlePermissionsCase +from odoo.tests.common import tagged, users +from odoo.tools import mute_logger + + +@tagged('knowledge_acl') +class TestKnowledgeArticlePermissions(KnowledgeArticlePermissionsCase): + + @users('employee') + def test_article_main_parent(self): + """ Test root article computation """ + article_roots = self.article_roots.with_env(self.env) + + articles_write = (self.article_write_contents + self.article_write_contents_children).with_env(self.env) + self.assertEqual(articles_write.root_article_id, article_roots[0]) + + articles_write = self.article_read_contents.with_env(self.env) + self.assertEqual(articles_write.root_article_id, article_roots[1]) + + # desynchronized still have a root (do as sudo) + self.assertEqual(self.article_write_desync.root_article_id, article_roots[0]) + self.assertEqual(self.article_read_desync.root_article_id, article_roots[1]) + + def test_article_permissions_desync(self): + """ Test computed fields based on permissions (independently from ACLs + aka not user_permission, ...). Main use cases: desynchronized articles + or articles without parents. """ + for (exp_inherited_permission, + exp_inherited_permission_parent_id, + exp_internal_permission + ), article in zip( + [('read', self.env['knowledge.article'], 'read'), + ('read', self.article_write_desync[0], False), + ('none', self.env['knowledge.article'], 'none'), + ('none', self.article_read_desync[0], False), + ('write', self.env['knowledge.article'], 'write'), + ('read', self.env['knowledge.article'], 'read'), + ], + self.article_write_desync + self.article_read_desync + self.article_roots + ): + self.assertEqual(article.inherited_permission, exp_inherited_permission, + f'Permission: wrong inherit computation for {article.name}: {article.inherited_permission} instead of {exp_inherited_permission}') + self.assertEqual(article.inherited_permission_parent_id, exp_inherited_permission_parent_id, + f'Permission: wrong inherit computation for {article.name}: {article.inherited_permission_parent_id.name} instead of {exp_inherited_permission_parent_id.name}') + self.assertEqual(article.internal_permission, exp_internal_permission, + f'Permission: wrong inherit computation for {article.name}: {article.internal_permission} instead of {exp_internal_permission}') + + @mute_logger('odoo.addons.base.models.ir_rule') + def test_article_permissions_inheritance_desync(self): + """ Test desynchronize (and therefore member propagation that should be + stopped). """ + article_desync = self.article_write_desync[0] + self.assertMembers(article_desync, 'read', {self.partner_employee_manager: 'write'}) + + # as employee w write perms + article_desync = article_desync.with_user(self.user_employee_manager) + self.assertTrue(article_desync.user_has_write_access) + self.assertTrue(article_desync.user_has_access) + + # as employee + article_desync = article_desync.with_user(self.user_employee) + self.assertFalse(article_desync.user_has_write_access) + self.assertTrue(article_desync.user_has_access) + + # as portal + article_desync = article_desync.with_user(self.user_portal) + self.assertFalse(article_desync.user_has_write_access) + self.assertFalse(article_desync.user_has_access, 'Permissions: member rights should not be fetch on parents') + + @mute_logger('odoo.addons.base.models.ir_rule') + @users('employee') + def test_article_permissions_inheritance_employee(self): + article_roots = self.article_roots.with_env(self.env) + + # roots: based on internal permissions + self.assertEqual(article_roots.mapped('user_has_write_access'), [True, False, False, True]) + self.assertEqual(article_roots.mapped('user_has_access'), [True, True, True, True]) + self.assertEqual(article_roots.mapped('user_permission'), ['write', 'read', 'read', 'write']) + + # write permission from ancestors + article_write_ancestor = self.article_write_contents[2].with_env(self.env) + self.assertEqual(article_write_ancestor.inherited_permission, 'write') + self.assertEqual(article_write_ancestor.inherited_permission_parent_id, self.article_roots[0]) + self.assertFalse(article_write_ancestor.internal_permission) + self.assertEqual(article_write_ancestor.user_permission, 'write') + + # write permission from ancestors overridden by internal permission + article_read_forced = self.article_write_contents[1].with_env(self.env) + self.assertEqual(article_read_forced.inherited_permission, 'read') + self.assertFalse(article_read_forced.inherited_permission_parent_id) + self.assertEqual(article_read_forced.internal_permission, 'read') + self.assertEqual(article_read_forced.user_permission, 'read') + + # write permission from ancestors overridden by member permission + article_read_member = self.article_write_contents[0].with_env(self.env) + self.assertEqual(article_read_member.inherited_permission, 'write') + self.assertEqual(article_read_member.inherited_permission_parent_id, self.article_roots[0]) + self.assertFalse(article_read_member.internal_permission) + self.assertEqual(article_read_member.user_permission, 'read') + + # forced lower than base article perm (see 'Community Paranoïa') + article_lower = self.article_read_contents[1].with_env(self.env) + self.assertEqual(article_lower.inherited_permission, 'write') + self.assertFalse(article_lower.inherited_permission_parent_id) + self.assertEqual(article_lower.internal_permission, 'write') + self.assertEqual(article_lower.user_permission, 'read') + + # read permission from ancestors + article_read_ancestor = self.article_read_contents[2].with_env(self.env) + self.assertEqual(article_read_ancestor.inherited_permission, 'read') + self.assertEqual(article_read_ancestor.inherited_permission_parent_id, self.article_roots[1]) + self.assertFalse(article_read_ancestor.internal_permission) + self.assertEqual(article_read_ancestor.user_permission, 'read') + + # permission denied + article_none = self.article_read_contents[3].with_env(self.env) + with self.assertRaises(exceptions.AccessError): + article_none.name + + @mute_logger('odoo.addons.base.models.ir_rule') + @users('portal_test') + def test_article_permissions_inheritance_portal(self): + article_roots = self.article_roots.with_env(self.env) + + with self.assertRaises(exceptions.AccessError): + article_roots.mapped('internal_permission') + + article_members = self.article_read_contents[0:2].with_env(self.env) + self.assertEqual(article_members.mapped('inherited_permission'), ['write', 'write']) # TDE: TOCHECK + self.assertEqual(article_members.mapped('internal_permission'), ['write', 'write']) # TDE: TOCHECK + self.assertEqual(article_members.mapped('user_has_write_access'), [False, False], 'Portal: can never write') + self.assertEqual(article_members.mapped('user_has_access'), [True, True], 'Portal: access through membership') + self.assertEqual(article_members.mapped('user_permission'), ['read', 'read']) + + @users('employee') + def test_article_permissions_employee_new_mode(self): + """ Test transient / cache mode: computed fields without IDs, ... """ + article = self.env['knowledge.article'].new({'name': 'Transient'}) + self.assertFalse(article.inherited_permission) + self.assertFalse(article.internal_permission) + self.assertTrue(article.user_has_write_access) + self.assertTrue(article.user_has_access) + self.assertEqual(article.user_permission, 'write') + + +@tagged('knowledge_internals', 'knowledge_management') +class KnowledgeArticlePermissionsInitialValues(KnowledgeArticlePermissionsCase): + """ Test initial values or our test data once so that other tests do not have + to do it. """ + + def test_initial_values(self): + article_roots = self.article_roots.with_env(self.env) + article_headers = self.article_headers.with_env(self.env) + + # roots: defaults on write, inherited = internal + self.assertEqual(article_roots.mapped('inherited_permission'), ['write', 'read', 'none', 'none']) + self.assertFalse(article_roots.inherited_permission_parent_id) + self.assertEqual(article_roots.mapped('internal_permission'), ['write', 'read', 'none', 'none']) + + # children: allow void permission, inherited = go up to first defined permission + self.assertEqual(article_headers.mapped('inherited_permission'), ['write', 'read', 'read']) + self.assertEqual( + [p.inherited_permission_parent_id for p in article_headers], + [article_roots[0], article_roots[1], article_roots[1]] + ) + self.assertEqual(article_headers.mapped('internal_permission'), [False, False, False]) + + @users('employee') + def test_initial_values_as_employee(self): + """ Ensure all tests have the same basis (user specific computed as + employee for acl-dependent tests) """ + article_write_inherit = self.article_write_contents[2].with_env(self.env) + + # initial values: write through inheritance + self.assertMembers(article_write_inherit, False, {self.partner_portal: 'read'}) + self.assertFalse(article_write_inherit.internal_permission) + self.assertFalse(article_write_inherit.is_desynchronized) + self.assertTrue(article_write_inherit.user_has_write_access) + self.assertTrue(article_write_inherit.user_has_access) + + article_write_inherit_as2 = article_write_inherit.with_user(self.user_employee2) + self.assertTrue(article_write_inherit_as2.user_has_write_access) + self.assertTrue(article_write_inherit_as2.user_has_access) + + +@tagged('knowledge_acl') +class TestKnowledgeArticlePermissionsTools(KnowledgeArticlePermissionsCase): + + @mute_logger('odoo.addons.base.models.ir_rule') + @users('employee') + def test_downgrade_internal_permission_none(self): + writable_as1 = self.article_write_contents[2].with_env(self.env) + writable_as2 = writable_as1.with_user(self.user_employee2) + self.assertEqual(writable_as2.user_has_access, True) + writable_root = self.article_roots[0].with_env(self.env) + writable_children = self.article_write_contents_children.with_env(self.env) + for child in writable_children: + self.assertEqual(child.inherited_permission_parent_id, writable_root) + + # downgrade write global perm to read + writable_as1._set_internal_permission('none') + writable_as1.flush_model() # ACLs are done using SQL + self.assertMembers( + writable_as1, 'none', + {self.partner_portal: 'read', # untouched by downgrade + self.env.user.partner_id: 'write'}, + 'Permission: lowering permission adds current user in members to have write access' + ) + self.assertTrue(writable_as1.is_desynchronized) + self.assertTrue(writable_as1.user_has_write_access) + self.assertTrue(writable_as1.user_has_access) + + # check internal permission has been lowered + with self.assertRaises(exceptions.AccessError): + writable_as2.body # trigger ACLs + + # check children inherits downgraded permissions from article + for child in writable_children: + self.assertEqual(child.inherited_permission, 'none', 'Permission: lowering permission should lower the permission of the children') + self.assertEqual(child.inherited_permission_parent_id, writable_as1, 'Permission: lowering permission should make the children inherit the permission from this article') + + @users('employee') + def test_downgrade_internal_permission_read(self): + writable_as1 = self.article_write_contents[2].with_env(self.env) + writable_as2 = writable_as1.with_user(self.user_employee2) + self.assertEqual(writable_as2.user_has_access, True) + writable_root = self.article_roots[0].with_env(self.env) + writable_children = self.article_write_contents_children.with_env(self.env) + for child in writable_children: + self.assertEqual(child.inherited_permission_parent_id, writable_root) + + # downgrade write global perm to read + writable_as1._set_internal_permission('read') + writable_as1.flush_model() # ACLs are done using SQL + self.assertMembers( + writable_as1, 'read', + {self.partner_portal: 'read', self.env.user.partner_id: 'write'}, + 'Permission: lowering permission adds current user in members to have write access' + ) + self.assertTrue(writable_as1.is_desynchronized) + self.assertTrue(writable_as1.user_has_write_access) + self.assertTrue(writable_as1.user_has_access) + self.assertFalse(writable_as2.user_has_write_access) + self.assertTrue(writable_as2.user_has_access) + + # check children inherits downgraded permissions from article + for child in writable_children: + self.assertEqual(child.inherited_permission, 'read', 'Permission: lowering permission should lower the permission of the children') + self.assertEqual(child.inherited_permission_parent_id, writable_as1, 'Permission: lowering permission should make the children inherit the permission from this article') + + @mute_logger('odoo.addons.base.models.ir_rule', 'odoo.models.unlink') + @users('employee') + def test_remove_member_inherited_rights(self): + """ Remove a member from a child inheriting rights: will desync """ + writable = self.article_write_contents[2].with_env(self.env) + self.assertTrue(writable.user_has_access) + self.assertTrue(writable.user_has_write_access) + self.assertMembers(writable, False, + {self.partner_portal: 'read'}) + + # set partner employee manager as writable member of its root + writable_root = writable.root_article_id + writable_root._add_members(self.partner_employee_manager, 'write') + self.assertMembers(writable_root, 'write', + {self.partner_employee_manager: 'write'}) + + # remove partner employee manager that has rights based on inheritance + writable_children = self.article_write_contents_children.with_env(self.env) + for child in writable_children: + self.assertIn( + self.partner_employee_manager.id, + child._get_article_member_permissions()[child.id], + 'Share Panel: if an article inherits a permission, its children should inherit that permission too') + manager_member = writable_root.article_member_ids.filtered(lambda m: m.partner_id == self.partner_employee_manager) + writable._remove_member(manager_member) + self.assertTrue(writable.is_desynchronized, + 'Permission: when removing a member having inherited rights it has be be desynchronized') + self.assertMembers(writable, 'write', + {self.partner_portal: 'read'}) + for child in writable_children: + self.assertNotIn( + self.partner_employee_manager.id, + child._get_article_member_permissions()[child.id], + 'Share Panel: when removing a member having inherited rights, the member should be removed from the children that inherited that right') + + # resync + writable.restore_article_access() + self.assertFalse(writable.is_desynchronized) + self.assertMembers(writable, False, + {self.partner_portal: 'read'}) + + # remove portal partner that has rights based on membership + portal_member = writable.article_member_ids.filtered(lambda m: m.partner_id == self.partner_portal) + writable._remove_member(portal_member) + self.assertFalse(writable.is_desynchronized) + self.assertMembers(writable, False, {}) + + @mute_logger('odoo.addons.base.models.ir_rule', 'odoo.models.unlink') + @users('employee') + def test_remove_member_leave_shared_article(self): + # Can remove self if no write access only if does not gain higher access rights while doing so. + # AKA: allow to leave shared articles. + shared_article = self.article_roots[2] + self.assertMembers(shared_article, 'none', + {self.env.user.partner_id: 'read', + self.partner_employee_manager: 'write'}) + + my_member = shared_article.article_member_ids.filtered( + lambda m: m.partner_id == self.env.user.partner_id) + shared_article._remove_member(my_member) + + self.assertMembers(shared_article, 'none', {self.partner_employee_manager: 'write'}) + + @mute_logger('odoo.models.unlink') + @users('employee') + def test_set_member_permission(self): + """ Test setting member-specific permission """ + writable = self.article_write_contents[2].with_env(self.env) + self.assertTrue(writable.user_has_access) + self.assertTrue(writable.user_has_write_access) + + # set partner employee manager as readable member of its root + writable_root = writable.root_article_id + writable_root._add_members(self.partner_employee_manager, 'read') + self.assertMembers(writable_root, 'write', + {self.partner_employee_manager: 'read'}) + + # update a member permission directly + portal_member = writable.article_member_ids.filtered(lambda m: m.partner_id == self.partner_portal) + writable._set_member_permission(portal_member, 'none') + self.assertMembers(writable, False, + {self.partner_portal: 'none'}) + + # upgrade a permission based on inheritance + manager_member_root = writable_root.article_member_ids.filtered(lambda m: m.partner_id == self.partner_employee_manager) + writable._set_member_permission(manager_member_root, 'write', is_based_on=True) + self.assertFalse(writable.is_desynchronized) + self.assertMembers(writable, False, + {self.partner_portal: 'none', + self.partner_employee_manager: 'write'}) + + # now test downgrading + manager_member = writable.article_member_ids.filtered(lambda m: m.partner_id == self.partner_employee_manager) + writable_root._set_member_permission(manager_member_root, 'write') + writable._remove_member(manager_member) + self.assertMembers(writable_root, 'write', + {self.partner_employee_manager: 'write'}) + self.assertMembers(writable, False, + {self.partner_portal: 'none'}) + + # downgrade a permission, should desynchronize from parent + writable_children = self.article_write_contents_children.with_env(self.env) + for child in writable_children: + self.assertEqual( + child._get_article_member_permissions()[child.id][self.partner_employee_manager.id]['permission'], + 'write', + 'Share Panel: if an article inherits a permission, its children should inherit that permission too') + writable_root._set_member_permission(manager_member_root, 'write') + writable._set_member_permission(manager_member_root, 'read', is_based_on=True) + self.assertTrue(writable.is_desynchronized, + 'Permission: when removing a member having inherited rights it has be be desynchronized') + self.assertMembers(writable, 'write', + {self.partner_portal: 'none', + self.partner_employee_manager: 'read'}) + for child in writable_children: + self.assertEqual( + child._get_article_member_permissions()[child.id][self.partner_employee_manager.id]['permission'], + 'read', + 'Share Panel: when downgrading a member having inherited rights, the member should be downgraded from the children that inherited that right') + + # adding a member to parent, should not be inherited by children + writable_root._add_members(self.partner_employee2, 'read') + self.assertNotIn( + self.partner_employee2.id, + writable._get_article_member_permissions()[writable.id], + 'Share Panel: when adding a member on a parent of a desynced article, the member should not be added on the desynced article') + for child in writable_children: + self.assertNotIn( + self.partner_employee2.id, + child._get_article_member_permissions()[child.id], + 'Share Panel: when adding a member on a parent of a desynced article, the member should not be added on the children of the desynced article') + + @mute_logger('odoo.addons.base.models.ir_rule') + @users('employee') + def test_update_internal_permission_escalation(self): + """ Check no privilege escalation is possible """ + # direct try at setting higher internal permission + readonly = self.article_read_contents[1].with_env(self.env) + self.assertTrue(readonly.user_has_access) + self.assertFalse(readonly.user_has_write_access) + writable = self.article_write_contents[2].with_env(self.env) + self.assertTrue(writable.user_has_access) + self.assertTrue(writable.user_has_write_access) + + with self.assertRaises(exceptions.AccessError, + msg='Permission: that is plain stupid trying to do this'): + readonly.write({'internal_permission': 'write'}) + with self.assertRaises(exceptions.AccessError, + msg='Permission: do not allow privilege escalation'): + readonly._set_internal_permission('write') + + @mute_logger('odoo.addons.base.models.ir_rule', 'odoo.models.unlink') + @users('employee') + def test_update_permissions_rights(self): + """ Check no privilege escalation is possible """ + # direct try at setting higher internal permission + readonly = self.article_read_contents[1].with_env(self.env) + self.assertTrue(readonly.user_has_access) + self.assertFalse(readonly.user_has_write_access) + with self.assertRaises(exceptions.AccessError, + msg='Permission: that is plain stupid trying to do this'): + readonly.write({'internal_permission': 'write'}) + with self.assertRaises(exceptions.AccessError, + msg='Permission: do not allow privilege escalation'): + readonly._set_internal_permission('write') + + other_member = readonly.article_member_ids.filtered(lambda m: m.partner_id == self.partner_portal) + with self.assertRaises(exceptions.AccessError, + msg='Permission: do not allow to remove other members when having only read access'): + readonly._remove_member(other_member) + self.assertMembers(readonly, 'write', + {self.env.user.partner_id: 'read', + self.partner_portal: 'read'}) + + # cannot remove self if no write access. + my_member = readonly.article_member_ids.filtered( + lambda m: m.partner_id == self.env.user.partner_id) + with self.assertRaises(exceptions.AccessError, + msg='Permission: do not allow to remove yourself when having only read access'): + readonly._remove_member(my_member) + self.assertMembers(readonly, 'write', + {self.env.user.partner_id: 'read', + self.partner_portal: 'read'}) + + +@tagged('knowledge_acl', 'knowledge_portal') +class TestKnowledgeArticlePortal(KnowledgeArticlePermissionsCase): + """ Portal users should have limited usage, they can read/write depending on permissions but can't: + - Modify the article hierarchy (move an article from a parent to another) + - Modify the article internal_permission + - Modify the article visibility ('is_article_visible_by_everyone') + - Create root articles (can only create UNDER articles to which they have write access) """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + # give write access to portal to the article root + cls.article_roots[0].write({ + 'article_member_ids': [(0, 0, { + 'partner_id': cls.user_portal.partner_id.id, + 'permission': 'write', + })] + }) + + # give read access to shared root + cls.article_roots[2].write({ + 'article_member_ids': [(0, 0, { + 'partner_id': cls.user_portal.partner_id.id, + 'permission': 'read', + })] + }) + + # create a second portal user to test the invite flow + cls.portal_user_2 = cls.env['res.partner'].create({'name': 'Portal User 2'}) + + @users('portal_test') + def test_article_create(self): + KnowledgeArticle = self.env['knowledge.article'] + article_root = KnowledgeArticle.browse(self.article_roots[0].id) + + # can create a child to a parent it has access to + KnowledgeArticle.create({ + 'name': 'Test Child Portal Article', + 'parent_id': article_root.id, + }) + + # can create a private article + KnowledgeArticle.create({ + 'name': 'Test Root Portal Article', + 'internal_permission': 'none', + 'article_member_ids': [(0, 0, { + 'partner_id': self.user_portal.partner_id.id, + 'permission': 'write', + })], + }) + + with self.assertRaises(exceptions.AccessError): + # cannot create a root (workspace) article + KnowledgeArticle.create({ + 'name': 'Test Root Portal Article', + }) + + @users('portal_test') + def test_article_leave(self): + article_root = self.env['knowledge.article'].browse(self.article_roots[0].id) + + with self.assertRaises(exceptions.AccessError): + # cannot leave an article, as it may interfere with the desync status of an article + # and we do not want portal users messing with that + article_root._remove_member(self.user_portal) + + @users('portal_test') + def test_article_membership_access(self): + """ Test that membership is necessary to access an article for portal users. + + This is a basic test to make sure that portal users ACLs follow the ones from internal users. + A more complete test suite is already present on internal users, and we do not wish to + duplicate all of those to test on portal. + (e.g: testing access rights escalation, testing recursive query on memberships, ...). + + Exception made for access to 'workspace' articles, which is unavailable for portal users, + they need specific membership access. """ + + # can read/write on main root as access has been explicitly granted + article_root = self.env['knowledge.article'].browse(self.article_roots[0].id) + + article_root.read(['name']) + article_root.write({'name': 'Updated Name'}) + + # can read on shared root as read access has been explicitly granted + shared_root = self.env['knowledge.article'].browse(self.article_roots[2].id) + shared_root.read(['name']) + with self.assertRaises(exceptions.AccessError): + shared_root.write({'name': 'Updated Name'}) + + # cannot access "readable root" as it's available in workspace to internal users only + readable_workspace_root = self.env['knowledge.article'].browse(self.article_roots[1].id) + with self.assertRaises(exceptions.AccessError): + readable_workspace_root.read(['name']) + + @users('portal_test') + def test_article_membership_management(self): + article_root = self.env['knowledge.article'].browse(self.article_roots[0].id) + + # add another member as sudo to test membership management + self.env['knowledge.article.member'].sudo().create({ + 'partner_id': self.user_employee.partner_id.id, + 'article_id': article_root.id, + 'permission': 'write', + }) + + # should be able to read the member + employee_member = article_root.article_member_ids.filtered( + lambda member: member.partner_id == self.user_employee.partner_id + ) + + with self.assertRaises(exceptions.AccessError): + # cannot set someone else as read access + article_root._set_member_permission(employee_member, 'read') + + with self.assertRaises(exceptions.AccessError): + # cannot invite other people to access the article + article_root.invite_members(self.portal_user_2, 'read') + + with self.assertRaises(exceptions.AccessError): + # cannot add members + article_root._add_members(self.portal_user_2, 'read') + + with self.assertRaises(exceptions.AccessError): + # cannot remove members + article_root._remove_member(employee_member) + + @users('portal_test') + def test_article_reorganize_private(self): + """" Although portal users can't write on some fields (see 'test_article_write'), notable + 'sequence' and 'parent_id', they should be allowed to re-organize their private articles. """ + + [private_1, private_2] = self.env['knowledge.article'].create([{ + 'name': 'Private 1', + 'internal_permission': 'none', + 'sequence': 1, + 'article_member_ids': [(0, 0, { + 'partner_id': self.user_portal.partner_id.id, + 'permission': 'write', + })], + }, { + 'name': 'Private 2', + 'internal_permission': 'none', + 'sequence': 2, + 'article_member_ids': [(0, 0, { + 'partner_id': self.user_portal.partner_id.id, + 'permission': 'write', + })], + }]) + + # invert the order + private_1.write({'sequence': 2}) + private_2.write({'sequence': 1}) + + # set private 2 as a child of private 1 + private_2.write({'parent_id': private_1.id}) + + # can mark his own private article as to be deleted + private_1.write({ + 'active': False, + 'to_delete': True, + }) + + @users('portal_test') + def test_article_write(self): + article_root = self.env['knowledge.article'].browse(self.article_roots[0].id) + + # can change the article name + article_root.write({'name': 'New Name'}) + + with self.assertRaises(exceptions.AccessError): + # cannot change the hierarchy + article_root.write({'parent_id': self.article_roots[1].id}) + + with self.assertRaises(exceptions.AccessError): + # cannot change the internal permission + article_root.write({'internal_permission': 'read'}) + + with self.assertRaises(exceptions.AccessError): + # cannot change the internal permission + article_root._set_internal_permission({'internal_permission': 'read'}) + + with self.assertRaises(exceptions.AccessError): + # cannot change the visibility + article_root.write({'is_article_visible_by_everyone': True}) + + with self.assertRaises(exceptions.AccessError): + # cannot archive an article + article_root.write({'active': False}) + + with self.assertRaises(exceptions.AccessError): + # cannot mark an article as to be deleted + article_root.write({'to_delete': True}) + + # cannot specify someone else as last editor of the article + article_root.write({'body': 'updated body'}) + self.assertEqual(article_root.last_edition_uid, self.user_portal) + + with self.assertRaises(exceptions.AccessError): + article_root.write({'last_edition_uid': self.user_employee.id}) + + @users('portal_test') + def test_article_stage(self): + # should be able to create/write/unlink an item stage under an article he has access to + article_stage = self.env['knowledge.article.stage'].create({ + 'name': 'Article Stage', + 'parent_id': self.article_roots[0].id + }) + + article_stage.write({'name': 'Updated Name'}) + article_stage.unlink() + + with self.assertRaises(exceptions.AccessError): + # No access to parent article -> should crash + self.env['knowledge.article.stage'].create({ + 'name': 'Article Stage', + 'parent_id': self.article_roots[1].id + }) + + +@tagged('knowledge_acl') +class TestKnowledgeArticleSearch(KnowledgeArticlePermissionsCase): + + @users('admin') + def test_article_business_flow_search_admin(self): + """ For business flows, we want to limit the articles based on what the + user has a real access to (as opposed to ACL access). + + This is especially true for the admin that has access to everything in + terms of ACLs, but should not see other users' private articles when + getting 'move_to' suggestions. """ + + Article = self.env['knowledge.article'] + private_employee_root = self.article_roots[-1] + article_header = self.article_headers[0] + # Creates article with explicit no access to admin + explicit_no_access = Article.create({ + 'name': 'Explicit No Access Article', + 'body': 'Content
', + 'internal_permission': 'write', + 'article_member_ids': [(0, 0, { + 'partner_id': self.partner_admin.id, + 'permission': 'none', + })], + }) + self.assertFalse(private_employee_root.user_has_access) + self.assertFalse(explicit_no_access.user_has_access) + + accessible_articles = Article.search([]) + # admin should have access to all articles, even the other users' private ones + # and the ones he has explicit member with no access. + self.assertTrue(article_header in accessible_articles) + self.assertTrue(private_employee_root in accessible_articles) + self.assertTrue(explicit_no_access in accessible_articles) + + # Potential parents for move To should not include those articles (nor the child of the article to move) + move_to_candidates = self.article_write_contents_children[1].with_env(self.env).get_valid_parent_options() + move_to_candidate_ids = [article['id'] for article in move_to_candidates] + self.assertTrue(article_header.id in move_to_candidate_ids) + self.assertFalse(private_employee_root.id in move_to_candidate_ids) + self.assertFalse(private_employee_root.id in move_to_candidate_ids) + self.assertFalse(explicit_no_access.id in move_to_candidate_ids) + + @users('admin') + def test_article_search_admin(self): + """ Test admin: can read / write everything but user_has_access and + user_has_write_access should still be based on real permissions. """ + self.assertTrue(self.env.user.has_group('base.group_system')) + articles = self.env['knowledge.article'].search([]) + expected = self.articles_all + self.assertEqual(articles, expected, + 'Search on user_has_write_access: aka write access (additional: %s, missing: %s)' % + ((articles - expected).mapped('name'), (expected - articles).mapped('name')) + ) + + articles = self.env['knowledge.article'].search([('user_has_write_access', '=', True)]) + expected = self.article_roots[0] + self.article_headers[0] + \ + self.article_write_contents[0] + self.article_write_contents[2] + \ + self.article_write_contents_children + \ + self.article_read_contents[0:2] + self.assertEqual(articles, expected, + 'Search on user_has_write_access: aka write access (additional: %s, missing: %s)' % + ((articles - expected).mapped('name'), (expected - articles).mapped('name')) + ) + + @users('employee') + def test_article_search_employee(self): + """ Test regular searches using permission-based ACLs """ + # explicitly remove an article, check it is not included (nor its child) + self.article_write_desync[0].write({ + 'article_member_ids': [ + (0, 0, {'partner_id': self.user_employee.partner_id.id, + 'permission': 'none'})] + }) + articles = self.env['knowledge.article'].search([]) + # not reachable: 'none', desynchronized 'none' (and their children) + expected = self.articles_all - self.article_read_contents[3] - self.article_write_desync - self.article_read_contents[3].child_ids + self.assertEqual(articles, expected, + 'Search on main article: aka everything except "none"-based articles (additional: %s, missing: %s)' % + ((articles - expected).mapped('name'), (expected - articles).mapped('name')) + ) + + # add its child as readable through membership and perform a new search + self.article_write_desync[1].write({ + 'article_member_ids': [ + (0, 0, {'partner_id': self.user_employee.partner_id.id, + 'permission': 'read'})] + }) + + articles = self.env['knowledge.article'].search([('root_article_id', '=', self.article_roots[0].id)]) + expected = self.article_roots[0] + self.article_headers[0] + \ + self.article_write_contents + self.article_write_contents_children + self.article_write_desync[1] + self.assertEqual(articles, expected, + 'Search on main article: aka read access on read root + its children (additional: %s, missing: %s)' % + ((articles - expected).mapped('name'), (expected - articles).mapped('name')) + ) + + @users('employee') + def test_article_search_employee_method_based(self): + """ Test search methods """ + articles = self.env['knowledge.article'].search([('user_has_write_access', '=', True)]) + expected = self.article_roots[0] + self.article_roots[3] + \ + self.article_headers[0] + \ + self.article_write_contents[2] + self.article_write_contents_children + \ + self.article_read_contents[0] + self.article_read_desync + self.assertEqual(articles, expected, + 'Search on user_has_write_access: aka write access (additional: %s, missing: %s)' % + ((articles - expected).mapped('name'), (expected - articles).mapped('name')) + ) diff --git a/addons_extensions/knowledge/tests/test_knowledge_article_sequence.py b/addons_extensions/knowledge/tests/test_knowledge_article_sequence.py new file mode 100644 index 000000000..e4b24e7a7 --- /dev/null +++ b/addons_extensions/knowledge/tests/test_knowledge_article_sequence.py @@ -0,0 +1,192 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.knowledge.tests.common import KnowledgeCommon +from odoo.tests.common import tagged, users +from odoo.tools import mute_logger + + +@tagged('knowledge_sequence') +class TestKnowledgeArticleSequence(KnowledgeCommon): + """ Test sequencing and auto-resequence of articles. """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + with mute_logger('odoo.models.unlink'): + cls.env['knowledge.article'].search([]).unlink() + + # HIERARCHY + # - Existing 1 seq=1 + # - Existing 2 seq=3 + # - Article 1 seq=4 + # - Article 1.1 + # - Article 1.2 + # - Article 1.2.1 + # - Article 1.3 + # - Article 2 seq=5 + + # define starting sequence for root articles + cls.article_root_noise = cls.env['knowledge.article'].create([ + {'internal_permission': 'write', + 'name': 'Existing1', + 'sequence': 1, + }, + {'internal_permission': 'write', + 'name': 'Existing2', + 'sequence': 3, + } + ]) + cls.article_private = cls._create_private_article(cls, 'Article1', target_user=cls.user_employee) + cls.article_children = cls.env['knowledge.article'].create([ + {'name': 'Article1.1', + 'parent_id': cls.article_private.id, + }, + {'name': 'Article1.2', + 'parent_id': cls.article_private.id, + }, + ]) + cls.article_children += cls.env['knowledge.article'].create([ + {'name': 'Article1.2.1', + 'parent_id': cls.article_children[1].id, + }, + ]) + cls.article_children += cls.env['knowledge.article'].create([ + {'name': 'Article1.3', + 'parent_id': cls.article_private.id, + } + ]) + cls.article_private2 = cls._create_private_article(cls, 'Article2', target_user=cls.user_employee) + + # flush everything to ease resequencing and date-based computation + cls.env.flush_all() + + @users('employee') + def test_initial_tree(self): + # parents + article_private = self.article_private.with_env(self.env) + article_children = self.article_children.with_env(self.env) + article_private2 = self.article_private2.with_env(self.env) + + self.assertFalse(article_private.parent_id) + self.assertEqual((article_children[0:2] + article_children[3:]).parent_id, article_private) + self.assertEqual(article_children[2].parent_id, article_children[1]) + self.assertFalse(article_private2.parent_id) + # ancestors + self.assertEqual((article_private + article_children).root_article_id, article_private) + self.assertEqual(article_private2.root_article_id, article_private2) + # categories + self.assertEqual(article_private.category, 'private') + self.assertEqual(set(article_children.mapped('category')), set(['private'])) + self.assertEqual(article_private2.category, 'private') + # user permission + self.assertEqual(article_private.user_permission, 'write') + self.assertEqual(set(article_children.mapped('user_permission')), set(['write'])) + self.assertEqual(article_private2.user_permission, 'write') + self.assertEqual(article_private.inherited_permission, 'none') + self.assertEqual(set(article_children.mapped('inherited_permission')), set(['none'])) + self.assertEqual(article_private2.inherited_permission, 'none') + # sequences + self.assertSortedSequence(article_private + article_private2) + self.assertSortedSequence(article_children[0:2] + article_children[3]) + + @users('employee') + def test_resequence_with_move(self): + """Checking the sequence of the articles""" + article_private = self.article_private.with_env(self.env) + article_children = self.article_children.with_env(self.env) + article_private2 = self.article_private2.with_env(self.env) + + # move last child "Article 1.3" before "Article 1.2" + last_child = article_children[3] + last_child.move_to(parent_id=article_private.id, before_article_id=article_children[1].id) + # expected + # - Article 1 + # - Article 1.1 + # - Article 1.3 + # - Article 1.2 + # - Article 1.2.1 + # - Article 6 + self.assertFalse(article_private.parent_id) + self.assertEqual((article_children[0:2] + article_children[3:]).parent_id, article_private) + self.assertEqual(article_children[2].parent_id, article_children[1]) + self.assertFalse(article_private2.parent_id) + self.assertSortedSequence(article_private + article_private2) + self.assertSortedSequence(article_children[0] + article_children[3] + article_children[1]) + + # move "Article 1.2.1" in first position under "Article 1" + article_children[2].move_to(parent_id=article_private.id, before_article_id=article_children[0].id) + # expected + # - Article 1 + # - Article 1.2.1 + # - Article 1.1 + # - Article 1.3 + # - Article 1.2 + # - Article 6 + self.assertFalse(article_private.parent_id) + self.assertEqual(article_children.parent_id, article_private) + self.assertFalse(article_private2.parent_id) + self.assertSortedSequence(article_private + article_private2) + self.assertSortedSequence(article_children[2] + article_children[0] + article_children[3] + article_children[1]) + + # move "Article 1.1" in last position under "Article 1" + article_children[0].move_to(parent_id=article_private.id, before_article_id=False) + # expected + # - Article 1 + # - Article 1.2.1 + # - Article 1.3 + # - Article 1.2 + # - Article 1.1 + # - Article 6 + self.assertFalse(article_private.parent_id) + self.assertEqual(article_children.parent_id, article_private) + self.assertFalse(article_private2.parent_id) + self.assertSortedSequence(article_private + article_private2) + self.assertSortedSequence(article_children[2] + article_children[3] + article_children[1] + article_children[0]) + + @users('employee') + def test_resequence_with_move_noparent(self): + """ Test move resetting parent_id should also compute sequence """ + article_private = self.article_private.with_env(self.env) + article_private_child = self.article_children[0].with_env(self.env) + article_private2 = self.article_private2.with_env(self.env) + article_root_noise = self.article_root_noise.with_env(self.env) + + self.assertEqual(article_private_child.sequence, 0) + article_private_child.move_to(category='private') + self.assertEqual(article_private_child.sequence, 6) + self.assertSortedSequence(article_root_noise + article_private + article_private2 + article_private_child) + article_private_child.move_to(before_article_id=self.article_root_noise[0].id) + self.assertEqual(article_private_child.sequence, 1) + self.assertSortedSequence(article_private_child + article_root_noise + article_private + article_private2) + + @users('employee') + def test_resequence_with_parent(self): + """Checking the sequence of the articles""" + existing_private = self.article_private.with_env(self.env) + new_private = self._create_private_article('NewPrivate') + self.assertFalse(new_private.parent_id, 'Sequencing: no parent should be forced') + self.assertEqual(new_private.sequence, 6, 'Sequencing: should be placed after Article2, end of "no root" list') + + new_private.write({'parent_id': existing_private.id}) + self.assertEqual(new_private.parent_id, existing_private, 'Sequencing: respect parent choice') + self.assertEqual(new_private.sequence, 3, + 'Sequencing: without any forced value, should be set last of all children') + + @users('employee') + def test_resequence_with_move_before_readonly_article(self): + """Test resequencing the article with move before readonly article""" + article_root_noise = self.article_root_noise.with_env(self.env) + + #making 1st article readonly + article_root_noise[0]._set_internal_permission('read') + + self.assertEqual(article_root_noise[0].sequence, 1) + self.assertEqual(article_root_noise[1].sequence, 3) + self.assertSortedSequence(article_root_noise[0] + article_root_noise[1]) + + article_root_noise[1].move_to(before_article_id=article_root_noise[0].id) + + self.assertEqual(article_root_noise[0].sequence, 2) + self.assertEqual(article_root_noise[1].sequence, 1) + self.assertSortedSequence(article_root_noise[1] + article_root_noise[0]) diff --git a/addons_extensions/knowledge/tests/test_knowledge_article_stage.py b/addons_extensions/knowledge/tests/test_knowledge_article_stage.py new file mode 100644 index 000000000..670a6fbdc --- /dev/null +++ b/addons_extensions/knowledge/tests/test_knowledge_article_stage.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests.common import tagged, HttpCase + + +@tagged('post_install', '-at_install', 'knowledge_article_stage') +class TestKnowledgeArticleStage(HttpCase): + def test_compute_stage_id(self): + """ When creating an article item, it should automatically be assigned + to the stage with the lowest sequence number that is associated with + the parent article.""" + article = self.env['knowledge.article'].create({ + 'name': 'Parent Article', + }) + stages = self.env['knowledge.article.stage'].create([{ + 'name': 'Lost', + 'sequence': 2, + 'fold': True, + 'parent_id': article.id, + }, { + 'name': 'Win', + 'sequence': 1, + 'parent_id': article.id, + }]) + article_items = self.env['knowledge.article'].create([{ + 'name': 'Article Item 1', + 'parent_id': article.id, + 'is_article_item': True, + }, { + 'name': 'Article Item 2', + 'parent_id': article.id, + 'is_article_item': True, + }]) + # Check that the article items are assigned to the stage with the lowest sequence. + self.assertEqual(article_items[0].stage_id, stages[1]) + self.assertEqual(article_items[1].stage_id, stages[1]) diff --git a/addons_extensions/knowledge/tests/test_knowledge_article_template.py b/addons_extensions/knowledge/tests/test_knowledge_article_template.py new file mode 100644 index 000000000..ca04026b4 --- /dev/null +++ b/addons_extensions/knowledge/tests/test_knowledge_article_template.py @@ -0,0 +1,280 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import json + +from lxml import html +from markupsafe import Markup +from urllib import parse + +from odoo.tests.common import tagged, HttpCase +from odoo.tools import mute_logger + + +@tagged('post_install', '-at_install', 'knowledge_article_template') +class TestKnowledgeArticleTemplate(HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + Article = cls.env["knowledge.article"] + Category = cls.env["knowledge.article.template.category"] + Stage = cls.env["knowledge.article.stage"] + + with mute_logger("odoo.models.unlink"): + Article.search([]).unlink() + Category.search([]).unlink() + Stage.search([]).unlink() + + cls.article = Article.create({ + "body": Markup("Hello world
"), + "name": "My Article", + }) + + cls.personal_category = Category.create({ + "name": "Personal" + }) + + cls.template = Article.create({ + "icon": "emoji", + "is_template": True, + "template_name": "Template", + "template_body": Markup("Lorem ipsum dolor sit amet
"), + "template_category_id": cls.personal_category.id, + }) + cls.child_template_1 = Article.create({ + "parent_id": cls.template.id, + "article_properties_definition": [{ + "name": "28db68689e91de10", + "type": "char", + "string": "My Text Field", + "default": "" + }], + "is_template": True, + "template_name": "Child 1", + "template_body": Markup(""" +Sint dicta facere eum excepturi
+ + """), + "template_category_id": cls.personal_category.id, + }) + + cls.env["ir.model.data"].create({ + "module": "knowledge", + "name": "knowledge_article_template_test", + "model": "knowledge.article", + "res_id": cls.child_template_1.id + }) + + cls.child_template_1_stage_new = Stage.create({ + "name": "New", + "sequence": 1, + "fold": False, + "parent_id": cls.child_template_1.id, + }) + cls.child_template_1_stage_ongoing = Stage.create({ + "name": "Ongoing", + "sequence": 2, + "fold": False, + "parent_id": cls.child_template_1.id, + }) + + cls.child_template_1_1 = Article.create({ + "parent_id": cls.child_template_1.id, + "article_properties": { + "28db68689e91de10": "Hi there" + }, + "is_template": True, + "template_name": "Child 1.1", + "template_body": Markup("Magni labore natus, sunt consequatur error
"), + "template_category_id": cls.personal_category.id, + }) + cls.child_template_1_2 = Article.create({ + "parent_id": cls.child_template_1.id, + "is_template": True, + "template_name": "Child 1.2", + "template_body": Markup("Ullam molestias error commodi dignissimos
"), + "template_category_id": cls.personal_category.id, + }) + cls.child_template_1_3 = Article.create({ + "parent_id": cls.child_template_1.id, + "is_article_item": True, + "stage_id": cls.child_template_1_stage_new.id, + "is_template": True, + "template_name": "Child 1.3", + "template_body": Markup("Commodi voluptatem inventore quod iure
"), + "template_category_id": cls.personal_category.id, + }) + cls.child_template_1_4 = Article.create({ + "parent_id": cls.child_template_1.id, + "is_article_item": True, + "stage_id": cls.child_template_1_stage_ongoing.id, + "is_template": True, + "template_name": "Child 1.4", + "template_body": Markup("Facilis esse ipsam quidem consectetur
"), + "template_category_id": cls.personal_category.id, + }) + cls.child_template_2 = Article.create({ + "parent_id": cls.template.id, + "is_template": True, + "template_name": "Child 2", + "template_body": Markup("Voluptate autem officia
"), + "template_category_id": cls.personal_category.id, + }) + + def test_apply_template(self): + """ Check that that a given template is properly applied to a given article. """ + dummy_article = self.env['knowledge.article'].create({'name': 'NoBody', 'body': False}) + dummy_article.apply_template(self.template.id, skip_body_update=True) + self.assertFalse(dummy_article.body) + self.assertEqual(dummy_article.icon, self.template.icon) + + self.article.apply_template(self.template.id, skip_body_update=False) + + # After applying the template on the article, the values of the article + # should have been updated and new child articles should have been created + # for the article. + + # First level: + self.assertEqual(self.article.body, Markup("Lorem ipsum dolor sit amet
")) + self.assertEqual(self.article.icon, self.template.icon) + self.assertEqual(len(self.article.child_ids), 2) + self.assertFalse(self.article.is_article_item) + self.assertFalse(self.article.is_template) + self.assertFalse(self.article.stage_id) + + # Second level: + [child_article_1, child_article_2] = self.article.child_ids.sorted("name") + self.assertEqual(child_article_1.article_properties_definition, [{ + "name": "28db68689e91de10", + "type": "char", + "string": "My Text Field", + "default": "" + }]) + + # Check that the ids stored in the embedded view have properly been updated + # to refer to the parent article. + + fragment = html.fragment_fromstring(child_article_1.body, create_parent="div") + embedded_views = list(fragment.xpath("//*[@data-embedded='view']")) + + self.assertEqual(len(embedded_views), 1) + embedded_props = json.loads(embedded_views[0].get("data-embedded-props")) + self.assertEqual(embedded_props, { + "viewProps": { + "actionXmlId": "knowledge.knowledge_article_item_action", + "displayName": "Article Items", + "viewType": "list", + "context": { + "active_id": child_article_1.id, + "default_parent_id": child_article_1.id, + "default_is_article_item": True + } + } + }) + + self.assertTrue(len(child_article_1.child_ids), 4) + self.assertFalse(child_article_1.is_article_item) + self.assertFalse(child_article_1.is_template) + self.assertFalse(child_article_1.stage_id) + + self.assertEqual(child_article_2.body, Markup("Voluptate autem officia
")) + self.assertFalse(child_article_2.child_ids) + self.assertFalse(child_article_2.is_article_item) + self.assertFalse(child_article_2.is_template) + self.assertFalse(child_article_2.stage_id) + + # Third level: + [child_article_1_1, child_article_1_2, child_article_1_3, child_article_1_4] = child_article_1.child_ids.sorted("name") + self.assertEqual(child_article_1_1.article_properties, { + "28db68689e91de10": "Hi there" + }) + self.assertEqual(child_article_1_1.body, Markup("Magni labore natus, sunt consequatur error
")) + self.assertFalse(child_article_1_1.child_ids) + self.assertFalse(child_article_1_1.is_article_item) + self.assertFalse(child_article_1_1.is_template) + self.assertFalse(child_article_1_1.stage_id) + + self.assertEqual(child_article_1_2.body, Markup("Ullam molestias error commodi dignissimos
")) + self.assertFalse(child_article_1_2.child_ids) + self.assertFalse(child_article_1_2.is_article_item) + self.assertFalse(child_article_1_2.is_template) + self.assertFalse(child_article_1_2.stage_id) + + [child_article_1_stage_new, child_article_1_stage_ongoing] = \ + self.env["knowledge.article.stage"].search([("parent_id", "=", child_article_1.id)]).sorted("name") + + self.assertEqual(child_article_1_3.body, Markup("Commodi voluptatem inventore quod iure
")) + self.assertFalse(child_article_1_3.child_ids) + self.assertTrue(child_article_1_3.is_article_item) + self.assertFalse(child_article_1_3.is_template) + self.assertEqual(child_article_1_3.stage_id, child_article_1_stage_new) + + self.assertEqual(child_article_1_4.body, Markup("Facilis esse ipsam quidem consectetur
")) + self.assertFalse(child_article_1_4.child_ids) + self.assertTrue(child_article_1_4.is_article_item) + self.assertFalse(child_article_1_4.is_template) + self.assertEqual(child_article_1_4.stage_id, child_article_1_stage_ongoing) + + def test_template_category_inheritance(self): + """ Check that the category of the child templates remain always + consistent with the root template. """ + + new_category = self.env["knowledge.article.template.category"].create({ + "name": "New Category" + }) + + # When the user updates the category of a template having a parent, + # the category of the template should be reset. + + self.child_template_1.write({ + "template_category_id": new_category.id + }) + self.assertEqual(self.template.template_category_id, self.personal_category) + self.assertEqual(self.child_template_1.template_category_id, self.personal_category) + self.assertEqual(self.child_template_1_1.template_category_id, self.personal_category) + self.assertEqual(self.child_template_1_2.template_category_id, self.personal_category) + self.assertEqual(self.child_template_1_3.template_category_id, self.personal_category) + self.assertEqual(self.child_template_1_4.template_category_id, self.personal_category) + self.assertEqual(self.child_template_2.template_category_id, self.personal_category) + + # When the user updates the category of the root template, the category + # of all child templates should be updated. + + self.template.write({ + "template_category_id": new_category.id + }) + self.assertEqual(self.template.template_category_id, new_category) + self.assertEqual(self.child_template_1.template_category_id, new_category) + self.assertEqual(self.child_template_1_1.template_category_id, new_category) + self.assertEqual(self.child_template_1_2.template_category_id, new_category) + self.assertEqual(self.child_template_1_3.template_category_id, new_category) + self.assertEqual(self.child_template_1_4.template_category_id, new_category) + self.assertEqual(self.child_template_2.template_category_id, new_category) + + def test_template_hierarchy(self): + """ Check that the templates are properly linked to each other. """ + self.assertFalse(self.article.child_ids) + # Check 'child_ids' field: + self.assertEqual(self.template.child_ids, self.child_template_1 + self.child_template_2) + self.assertEqual(self.child_template_1.child_ids, \ + self.child_template_1_1 + self.child_template_1_2 + self.child_template_1_3 + self.child_template_1_4) + self.assertFalse(self.child_template_2.child_ids) + # Check 'parent_id' field: + self.assertFalse(self.template.parent_id) + self.assertEqual(self.child_template_1.parent_id, self.template) + self.assertEqual(self.child_template_1_1.parent_id, self.child_template_1) + self.assertEqual(self.child_template_1_2.parent_id, self.child_template_1) + self.assertEqual(self.child_template_1_3.parent_id, self.child_template_1) + self.assertEqual(self.child_template_1_4.parent_id, self.child_template_1) + self.assertEqual(self.child_template_2.parent_id, self.template) diff --git a/addons_extensions/knowledge/tests/test_knowledge_article_thread.py b/addons_extensions/knowledge/tests/test_knowledge_article_thread.py new file mode 100644 index 000000000..627cb4212 --- /dev/null +++ b/addons_extensions/knowledge/tests/test_knowledge_article_thread.py @@ -0,0 +1,119 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests.common import tagged +from odoo.addons.base.tests.common import HttpCaseWithUserDemo +from odoo.addons.knowledge.tests.test_knowledge_article_business import KnowledgeCommonBusinessCase + + +@tagged('post_install', '-at_install', 'knowledge', 'knowledge_tour', 'knowledge_comments') +class TestKnowledgeArticleTours(HttpCaseWithUserDemo): + + @classmethod + def setUpClass(cls): + """ The test article body contains custom selectors to ease the test steps as well as a + pre-existing comment on the second paragraph. """ + + super().setUpClass() + + cls.test_article = cls.env['knowledge.article'].create([{ + 'name': 'Sepultura', + 'is_article_visible_by_everyone': True, + 'internal_permission': 'write', + }]) + + cls.test_article_thread = cls.env['knowledge.article.thread'].create({ + 'article_id': cls.test_article.id, + 'article_anchor_text': f""" + + Lorem ipsum dolor + + """, + }) + cls.test_article_thread.message_post( + body="Marc, can you check this?", + message_type="comment" + ) + + cls.test_article.write({ + 'body': f""" ++ Lorem ipsum dolor sit amet, +
++ + Lorem ipsum dolor commented + +
+ """ + }) + + def test_knowledge_article_comments(self): + self.start_tour('/odoo', 'knowledge_article_comments', login='demo') + + # assert messages and resolved status + self.assertTrue(self.test_article_thread.is_resolved) + expected_messages = [ + "Marc, can you check this?", + "Sure thing boss, all done!", + "Oops forgot to mention, will be done in task-112233", + ] + + for message, expected_message in zip( + self.test_article_thread.message_ids + .filtered(lambda message: message.message_type == 'comment') + .sorted('create_date').mapped('body'), + expected_messages + ): + self.assertIn(expected_message, message) + + new_thread = self.env['knowledge.article.thread'].search([ + ('article_id', '=', self.test_article.id), + ('id', '!=', self.test_article_thread.id), + ]) + self.assertEqual(len(new_thread), 1) + self.assertEqual(len(new_thread.message_ids), 1) + self.assertIn("My Knowledge Comment", new_thread.message_ids[0].body) + + +class TestKnowledgeArticleThreadCrud(KnowledgeCommonBusinessCase): + + def test_knowledge_article_thread_create_w_unsafe_anchors(self): + new_thread = self.env['knowledge.article.thread'].create({ + 'article_id': self.article_workspace.id, + 'article_anchor_text': f""" + + Text + + """, + }) + self.assertEqual("Anchor Text", new_thread.article_anchor_text) + + new_thread.write({ + 'article_anchor_text': f""" + + Purified + + """ + }) + self.assertEqual("Should be Purified", new_thread.article_anchor_text) + + +class TestKnowledgeArticleThreadMail(KnowledgeCommonBusinessCase): + + def test_knowledge_article_thread_message_post_filtered_partners(self): + new_thread = self.env['knowledge.article.thread'].create({ + 'article_id': self.article_workspace.id, + 'article_anchor_text': f""" + + Text + + """, + }) + self.env["mail.followers"]._insert_followers("knowledge.article.thread", new_thread.ids, (self.partner_portal + self.env.user.partner_id + self.partner_admin).ids) + message_posted = new_thread.message_post(body="Prout") + + self.assertFalse(message_posted.partner_ids, "No specific partners to notify") + + message_posted = new_thread.message_post(body="Prout", partner_ids=(self.partner_portal + self.partner_admin).ids) + self.assertEqual(message_posted.partner_ids.ids, (self.partner_portal + self.partner_admin).ids, "Only specifically tagged partners should be notified") diff --git a/addons_extensions/knowledge/tests/test_knowledge_article_thread_permissions.py b/addons_extensions/knowledge/tests/test_knowledge_article_thread_permissions.py new file mode 100644 index 000000000..2325d2dc2 --- /dev/null +++ b/addons_extensions/knowledge/tests/test_knowledge_article_thread_permissions.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.knowledge.tests.common import KnowledgeArticlePermissionsCase +from odoo.tests.common import tagged, users +from odoo.exceptions import AccessError + +@tagged('knowledge_comments') +class TestKnowledgeArticleThreadPermissions(KnowledgeArticlePermissionsCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + Threads = cls.env['knowledge.article.thread'].with_context({'mail_create_nolog': True}) + + # Every internal user can write on it + cls.writable_article = cls.article_roots[0] + cls.writable_article.invite_members(cls.partner_portal, 'write') + cls.workspace_thread = Threads.create({ + 'article_id': cls.writable_article.id + }) + cls.workspace_thread.message_post(body='This is a public Thread') + + # Employee is readonly + cls.shared_article = cls.article_roots[2] + cls.shared_thread = Threads.create({ + 'article_id': cls.shared_article.id + }) + cls.shared_thread.message_post(body='This is a shared Thread') + + # Only employee_manager can write on it + cls.private_article = cls.env['knowledge.article'].create([ + {'article_member_ids': [ + (0, 0, {'partner_id': cls.partner_employee_manager.id, + 'permission': 'write', + }), + ], + 'internal_permission': 'none', + 'name': 'Private Root', + }]) + cls.private_thread = Threads.create({ + 'article_id': cls.private_article.id + }) + cls.private_thread.message_post(body='This is a private Thread') + + @users('employee') + def test_create_article_thread_as_employee(self): + article = self.writable_article.with_env(self.env) + # writable article + self.env['knowledge.article.thread'].create([{ + 'article_id': article.id, + }]) + with self.assertRaises(AccessError): + # readonly article + self.env['knowledge.article.thread'].create([{ + 'article_id': self.shared_article.with_env(self.env).id + }]) + + @users('employee') + def test_read_article_thread_as_employee(self): + private_thread = self.private_thread.with_env(self.env) + shared_thread = self.shared_thread.with_env(self.env) + workspace_thread = self.workspace_thread.with_env(self.env) + + # When you have access to an article you can write and read on threads + self.assertFalse(workspace_thread.is_resolved) + self.assertFalse(shared_thread.is_resolved) + + #* No access to the article = No access to the linked thread + with self.assertRaises(AccessError): + private_thread.is_resolved + + @users('portal_test') + def test_read_article_thread_as_portal(self): + private_thread = self.private_thread.with_env(self.env) + shared_thread = self.shared_thread.with_env(self.env) + workspace_thread = self.workspace_thread.with_env(self.env) + + # When you have access to an article you can write and read on threads + self.assertFalse(workspace_thread.is_resolved) + with self.assertRaises(AccessError): + shared_thread.is_resolved + + #* No access to the article = No access to the linked thread + with self.assertRaises(AccessError): + private_thread.is_resolved + + @users('employee') + def test_security_thread_resolution(self): + base_thread = self.private_thread.with_env(self.env) + + # No access to the article + with self.assertRaises(AccessError): + base_thread.write({'is_resolved': True}) + + base_thread = self.workspace_thread.with_env(self.env) + # Access to the article + self.assertFalse(base_thread.is_resolved) + base_thread.write({'is_resolved': True}) + self.assertTrue(base_thread.is_resolved) + + @users('portal_test') + def test_message_post_as_portal(self): + base_thread = self.private_thread.with_env(self.env) + with self.assertRaises(AccessError): + base_thread.message_post(body="It raises an error because of no access") + + self.private_article.sudo().invite_members(self.partner_portal, 'read') + + self.assertMembers(self.private_article, 'none', {self.partner_employee_manager: 'write', self.env.user.partner_id: 'read'}) + + message = base_thread.message_post(body="Hello Everyone", partner_ids=[self.partner_employee.id, self.partner_employee_manager.id], tracking_value_ids=[1, 2, 3]) + self.assertEqual(len(base_thread.sudo().message_ids), 2, "Portal user should be able to post a message") + self.assertListEqual(message.tracking_value_ids.ids, [], "Tracking values should have been filltered") + + @users('employee') + def test_message_post_as_employee(self): + base_thread = self.shared_article.with_env(self.env) + + self.assertEqual(len(base_thread.message_ids), 1) + base_thread.message_post(body="Hello Friend") + self.assertEqual(len(base_thread.message_ids), 2, "A message should have been posted") diff --git a/addons_extensions/knowledge/tests/test_knowledge_editor_commands.py b/addons_extensions/knowledge/tests/test_knowledge_editor_commands.py new file mode 100644 index 000000000..606afb769 --- /dev/null +++ b/addons_extensions/knowledge/tests/test_knowledge_editor_commands.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import base64 +from markupsafe import Markup + +from odoo.tests.common import tagged +from odoo.addons.base.tests.common import HttpCaseWithUserDemo + + +@tagged('post_install', '-at_install', 'knowledge', 'knowledge_tour') +class TestKnowledgeEditorCommands(HttpCaseWithUserDemo): + """ + This test suit run tours to test the new editor commands of Knowledge. + """ + @classmethod + def setUpClass(cls): + super(TestKnowledgeEditorCommands, cls).setUpClass() + # remove existing articles to ease tour management + cls.env['knowledge.article'].search([]).unlink() + + [cls.article, cls.linked_article] = cls.env['knowledge.article'].create([{ + 'name': 'EditorCommandsArticle', + 'body': Markup('EditorCommandsArticle Content
Lorem ipsum dolor sit amet, consectetur adipisicing elit.
'), + }) + + self.start_tour('/odoo', 'knowledge_load_template', login='admin') + article = self.env['knowledge.article'].search([('id', '!=', template.id)], limit=1) + self.assertTrue(bool(article)) + + # Strip collaborative steps ids from the body for content-only + # comparison + body = re.sub(r'\s*data-last-history-steps="[^"]*"', '', article.body) + body = re.sub(r'\s*data-oe-version="[^"]*"', '', body) + + self.assertEqual(template.template_body, body) + self.assertEqual(template.icon, article.icon) + self.assertEqual(template.article_properties_definition, article.article_properties_definition) + + def test_knowledge_main_flow(self): + + # Patching 'now' to allow checking the order of trashed articles, as + # they are sorted using their deletion date which is based on the + # 'write_date' field + self.patch(self.env.cr, 'now', lambda: fields.Datetime.now() - timedelta(days=1)) + article_1 = self.env['knowledge.article'].create({ + 'name': 'Article 1', + 'active': False, + 'to_delete': True, + }) + article_1.flush_recordset() + + # as the knowledge.article#_resequence method is based on write date + # force the write_date to be correctly computed + # otherwise it always returns the same value as we are in a single transaction + self.patch(self.env.cr, 'now', fields.Datetime.now) + self.env['knowledge.article'].create({ + 'name': 'Article 2', + 'active': False, + 'to_delete': True, + }) + self.env['knowledge.article'].create({ + 'name': 'Article 3', + 'internal_permission': 'write', + 'parent_id': False, + 'is_article_visible_by_everyone': True, + }) + with self.mock_mail_gateway(), self.mock_mail_app(): + self.start_tour('/odoo', 'knowledge_main_flow_tour', login='admin') + + # check our articles were correctly created + # with appropriate default values (section / internal_permission) + private_article = self.env['knowledge.article'].search([('name', '=', 'My Private Article')]) + self.assertTrue(bool(private_article)) + self.assertEqual(private_article.category, 'private') + self.assertEqual(private_article.internal_permission, 'none') + + workspace_article = self.env['knowledge.article'].search([('name', '=', 'My Workspace Article')]) + self.assertTrue(bool(workspace_article)) + self.assertEqual(workspace_article.category, 'workspace') + self.assertEqual(workspace_article.internal_permission, 'write') + + children_workspace_articles = workspace_article.child_ids.sorted('sequence') + self.assertEqual(len(children_workspace_articles), 2) + + child_article_1 = children_workspace_articles.filtered( + lambda article: article.name == 'Child Article 1') + child_article_2 = children_workspace_articles.filtered( + lambda article: article.name == 'Child Article 2') + + # as we re-ordered children, article 2 should come first + self.assertEqual(children_workspace_articles[0], child_article_2) + self.assertEqual(children_workspace_articles[1], child_article_1) + + # workspace article should have one partner invited on it + invited_member = workspace_article.article_member_ids.filtered(lambda member: member.partner_id != workspace_article.create_uid.partner_id) + self.assertEqual(len(invited_member), 1) + invited_partner = invited_member.partner_id + self.assertEqual(len(invited_partner), 1) + self.assertEqual(invited_partner.name, 'micheline@knowledge.com') + self.assertEqual(invited_partner.email, 'micheline@knowledge.com') + # check that the partner received an invitation link + invitation_message = self.env['mail.message'].search([ + ('partner_ids', 'in', invited_partner.id) + ]) + self.assertEqual(len(invitation_message), 1) + self.assertIn( + workspace_article._get_invite_url(invited_partner), + self._new_mails.body_html + ) + + # as we re-ordered our favorites, private article should come first + article_favorites = self.env['knowledge.article.favorite'].search([]) + self.assertEqual(len(article_favorites), 2) + self.assertEqual(article_favorites[0].article_id, private_article) + self.assertEqual(article_favorites[1].article_id, workspace_article) + + def test_knowledge_main_flow_portal(self): + """ Same goal as 'test_knowledge_main_flow' but for a portal user. + Portal users have limited rights, they can only access articles to which they have been + given specific write access to. """ + + # as the knowledge.article#_resequence method is based on write date + # force the write_date to be correctly computed + # otherwise it always returns the same value as we are in a single transaction + self.patch(self.env.cr, 'now', fields.Datetime.now) + + # create initial set of data: + # - one regular internal article + # - one article to which portal has access to + self.env['knowledge.article'].create([{ + 'name': "Internal Workspace Article", + 'internal_permission': 'write', + 'parent_id': False, + 'is_article_visible_by_everyone': True, + }, { + 'name': "Workspace Article", + 'body': "Content of Workspace Article
", + 'internal_permission': 'write', + 'parent_id': False, + 'is_article_visible_by_everyone': True, + 'article_member_ids': [(0, 0, { + 'partner_id': self.user_portal.partner_id.id, + 'permission': 'write', + })] + }]) + + self.start_tour('/knowledge/home', 'knowledge_main_flow_tour_portal', login='portal_test') + + # check our articles were correctly created + # with appropriate default values (section / internal_permission) + private_article = self.env['knowledge.article'].search([('name', '=', "My Private Article")]) + self.assertTrue(bool(private_article)) + self.assertEqual(private_article.category, 'private') + self.assertEqual(private_article.internal_permission, 'none') + + workspace_article = self.env['knowledge.article'].search([('name', '=', "Workspace Article")]) + # check that workspace article's content has been properly modified + self.assertIn("Edited Content of Workspace Article", workspace_article.body, + "Portal should have been able to modify the article content as he as direct access") + + children_workspace_articles = workspace_article.child_ids.sorted('sequence') + self.assertEqual(len(children_workspace_articles), 2, + "Portal should have been able to create 2 children") + + # as we re-ordered our favorites, private article should come first + article_favorites = self.env['knowledge.article.favorite'].search([]) + self.assertEqual(len(article_favorites), 2) + self.assertEqual(article_favorites[0].article_id, private_article) + self.assertEqual(article_favorites[1].article_id, workspace_article) + + def test_knowledge_pick_emoji(self): + """This tour will check that the emojis of the form view are properly updated + when the user picks an emoji from an emoji picker.""" + self.start_tour('/odoo', 'knowledge_pick_emoji_tour', login='admin') + + def test_knowledge_cover_selector(self): + """Check the behaviour of the cover selector when unsplash credentials + are not set. + """ + with io.BytesIO() as f: + Image.new('RGB', (50, 50)).save(f, 'PNG') + f.seek(0) + image = base64.b64encode(f.read()) + attachment = self.env['ir.attachment'].create({ + 'name': 'odoo_logo.png', + 'datas': image, + 'res_model': 'knowledge.cover', + 'res_id': 0, + }) + self.env['knowledge.cover'].create({'attachment_id': attachment.id}) + self.start_tour('/odoo', 'knowledge_cover_selector_tour', login='admin') + + def test_knowledge_readonly_favorite(self): + """Make sure that a user can add readonly articles to its favorites and + resequence them. + """ + articles = self.env['knowledge.article'].create([{ + 'name': 'Readonly Article 1', + 'internal_permission': 'read', + 'article_member_ids': [(0, 0, { + 'partner_id': self.env.ref('base.partner_admin').id, + 'permission': 'write', + })], + 'is_article_visible_by_everyone': True, + }, { + 'name': 'Readonly Article 2', + 'internal_permission': False, + 'article_member_ids': [(0, 0, { + 'partner_id': self.env.ref('base.partner_admin').id, + 'permission': 'write', + }), (0, 0, { + 'partner_id': self.partner_demo.id, + 'permission': 'read', + })], + 'is_article_visible_by_everyone': True, + }]) + + self.start_tour('/knowledge/article/%s' % articles[0].id, 'knowledge_readonly_favorite_tour', login='demo') + + self.assertTrue(articles[0].with_user(self.user_demo.id).is_user_favorite) + self.assertTrue(articles[1].with_user(self.user_demo.id).is_user_favorite) + self.assertGreater( + articles[0].with_user(self.user_demo.id).user_favorite_sequence, + articles[1].with_user(self.user_demo.id).user_favorite_sequence, + ) + + def test_knowledge_resequence_children_of_readonly_parent_tour(self): + """Make sure that a user can move children articles under a readonly + parent. + """ + parent = self.env['knowledge.article'].create({ + 'name': 'Readonly Parent', + 'internal_permission': 'read', + 'article_member_ids': [(0, 0, { + 'partner_id': self.env.ref('base.partner_admin').id, + 'permission': 'write', + })] + }) + self.env['knowledge.article'].create([{ + 'name': 'Child 1', + 'internal_permission': 'write', + 'sequence': 1, + 'parent_id': parent.id, + }, { + 'name': 'Child 2', + 'internal_permission': 'write', + 'sequence': 2, + 'parent_id': parent.id, + }]) + self.start_tour('/knowledge/article/%s' % parent.id, 'knowledge_resequence_children_of_readonly_parent_tour', login='demo') + + def test_knowledge_properties_tour(self): + """Test article properties panel""" + parent_article = self.env['knowledge.article'].create([{ + 'name': 'ParentArticle', + 'sequence': 1, + 'is_article_visible_by_everyone': True, + }, { + 'name': 'InheritPropertiesArticle', + 'sequence': 2, + 'is_article_visible_by_everyone': True, + }])[0] + self.env['knowledge.article'].create({ + 'name': 'ChildArticle', + 'parent_id': parent_article.id + }) + self.start_tour('/odoo', 'knowledge_properties_tour', login='admin') + + def test_knowledge_items_search_favorites_tour(self): + """Test search favorites for items view""" + self.env['knowledge.article'].create([{'name': 'Article 1', 'is_article_visible_by_everyone': True}]) + self.start_tour('/odoo', 'knowledge_items_search_favorites_tour', login='admin') + + def test_knowledge_search_favorites_tour(self): + """Test search favorites with searchModel state""" + self.env['knowledge.article'].create([{'name': 'Article 1', 'is_article_visible_by_everyone': True}]) + self.start_tour('/odoo', 'knowledge_search_favorites_tour', login='admin') + + @users('admin') + def test_knowledge_sidebar(self): + # This tour checks that the features of the sidebar work as expected + self.start_tour('/odoo', 'knowledge_sidebar_tour', login='admin', timeout=100) + + # Check section create button and article icon button + workspace_article = self.env['knowledge.article'].search([('name', '=', 'Workspace Article')]) + self.assertTrue(bool(workspace_article)) + self.assertEqual(workspace_article.category, 'workspace') + self.assertFalse(workspace_article.parent_id) + self.assertEqual(workspace_article.icon, '🥵') + + # Check article create and icon buttons + workspace_child = self.env['knowledge.article'].search([('name', '=', 'Workspace Child')]) + self.assertEqual(workspace_child.parent_id, workspace_article) + self.assertEqual(workspace_child.icon, '😬') + self.assertTrue(workspace_child.is_user_favorite) + + # Check drag and drop to trash + shared_article = self.env['knowledge.article'].with_context(active_test=False).search([('name', '=', 'Shared Article')]) + self.assertTrue(bool(shared_article)) + self.assertEqual(shared_article.category, 'shared') + self.assertFalse(shared_article.active) + + # Check favorites resequencing + private_article = self.env['knowledge.article'].search([('name', '=', 'Private Article')]) + self.assertTrue(bool(private_article)) + self.assertEqual(private_article.category, 'private') + self.assertFalse(private_article.parent_id) + self.assertGreater(private_article.user_favorite_sequence, workspace_child.user_favorite_sequence) + + # Check articles resequencing and article icon button + private_children = private_article.child_ids.sorted('sequence') + self.assertEqual(private_children[0].name, 'Private Child 3') + self.assertEqual(private_children[0].icon, '🥶') + self.assertEqual(private_children[1].name, 'Private Child 4') + self.assertEqual(private_children[2].name, 'Private Child 1') + + # Check drag and drop to other section + moved_to_share = self.env['knowledge.article'].with_context(active_test=False).search([('name', '=', 'Moved to Share')]) + self.assertTrue(bool(moved_to_share)) + self.assertEqual(moved_to_share.parent_id, shared_article) + self.assertEqual(moved_to_share.category, 'shared') + self.assertFalse(moved_to_share.active) + + # Check drag and drop to root and to trash + private_child_2 = self.env['knowledge.article'].with_context(active_test=False).search([('name', '=', 'Private Child 2')]) + self.assertTrue(bool(private_child_2)) + self.assertFalse(private_child_2.parent_id) + self.assertGreater(private_child_2.sequence, private_article.sequence) + self.assertFalse(private_child_2.active) + + # Check that some features are restricted with read only articles + private_article.write({ + 'internal_permission': 'read', + 'is_article_visible_by_everyone': True, + 'sequence': workspace_article.sequence+1, + }) + # show the workspace article in the sidebar + workspace_article.write({ + 'is_article_visible_by_everyone': True, + }) + self.start_tour('/odoo', 'knowledge_sidebar_readonly_tour', login='demo') + + # Check that articles did not move + self.assertFalse(workspace_article.parent_id) + self.assertGreater(private_article.sequence, workspace_article.sequence) + +@tagged('external', 'post_install', '-at_install') +@skipIf(not os.getenv("UNSPLASH_APP_ID") or not os.getenv("UNSPLASH_ACCESS_KEY"), "no unsplash credentials") +class TestKnowledgeUIWithUnsplash(TestKnowledgeUICommon): + @classmethod + def setUpClass(cls): + super(TestKnowledgeUIWithUnsplash, cls).setUpClass() + + cls.UNSPLASH_APP_ID = os.getenv("UNSPLASH_APP_ID") + cls.UNSPLASH_ACCESS_KEY = os.getenv("UNSPLASH_ACCESS_KEY") + + cls.env["ir.config_parameter"].set_param("unsplash.app_id", cls.UNSPLASH_APP_ID) + cls.env["ir.config_parameter"].set_param("unsplash.access_key", cls.UNSPLASH_ACCESS_KEY) + + def test_knowledge_cover_selector_unsplash(self): + """Check the behaviour of the cover selector when unsplash credentials + are set. + """ + self.start_tour('/odoo', 'knowledge_random_cover_tour', login='demo') diff --git a/addons_extensions/knowledge/tests/test_knowledge_performance.py b/addons_extensions/knowledge/tests/test_knowledge_performance.py new file mode 100644 index 000000000..13a1807d9 --- /dev/null +++ b/addons_extensions/knowledge/tests/test_knowledge_performance.py @@ -0,0 +1,189 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.knowledge.tests.common import KnowledgeCommonWData, KnowledgeArticlePermissionsCase +from odoo.tests.common import tagged, users, warmup +from odoo.tools import mute_logger + + +@tagged('knowledge_performance', 'post_install', '-at_install') +class KnowledgePerformanceCase(KnowledgeCommonWData): + + def setUp(self): + super().setUp() + # patch registry to simulate a ready environment + self.patch(self.env.registry, 'ready', True) + + @users('admin') + @warmup + def test_article_copy_batch(self): + """ Test performance of batch-copying articles, which implies notably + a descendants checks which might be costly. + + Done as admin as only admin has access to Duplicate button currently.""" + with self.assertQueryCount(admin=49): + workspace_children = self.workspace_children.with_env(self.env) + shared = self.article_shared.with_env(self.env) + _duplicates = (workspace_children + shared).copy_batch() + self.assertEqual(len(_duplicates), 3) + + @users('employee') + @warmup + def test_article_creation_single_shared_grandchild(self): + """ Test with 2 levels of hierarchy in a private/shared environment """ + with self.assertQueryCount(employee=21): + _article = self.env['knowledge.article'].create({ + 'body': 'Hello
', + 'name': 'Article in shared', + 'parent_id': self.shared_children[0].id, + }) + + self.assertEqual(_article.category, 'shared') + + @users('employee') + @warmup + def test_article_creation_single_workspace(self): + with self.assertQueryCount(employee=19): + _article = self.env['knowledge.article'].create({ + 'body': 'Hello
', + 'name': 'Article in workspace', + 'parent_id': self.article_workspace.id, + }) + + self.assertEqual(_article.category, 'workspace') + + @users('employee') + @warmup + def test_article_creation_multi_roots(self): + with self.assertQueryCount(employee=15): + _article = self.env['knowledge.article'].create([ + {'body': 'Hello
', + 'internal_permission': 'write', + 'name': f'Article {index} in workspace', + } + for index in range(10) + ]) + + @users('employee') + @warmup + def test_article_creation_multi_shared_grandchild(self): + with self.assertQueryCount(employee=21): + _article = self.env['knowledge.article'].create([ + {'body': 'Hello
', + 'name': f'Article {index} in workspace', + 'parent_id': self.shared_children[0].id, + } + for index in range(10) + ]) + + @users('employee') + @warmup + def test_article_favorite(self): + with self.assertQueryCount(employee=15): + shared_article = self.shared_children[0].with_env(self.env) + shared_article.action_toggle_favorite() + + @users('employee') + @warmup + def test_article_get_valid_parent_options(self): + with self.assertQueryCount(employee=6): + child_writable_article = self.workspace_children[1].with_env(self.env) + # don't check actual results, those are tested in ``TestKnowledgeArticleUtilities`` class + _res = child_writable_article.get_valid_parent_options(search_term="") + + @users('employee') + @warmup + def test_article_home_page(self): + with self.assertQueryCount(employee=20): + self.env['knowledge.article'].action_home_page() + + @mute_logger('odoo.addons.base.models.ir_rule', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink', 'odoo.tests') + @users('employee') + @warmup + def test_article_invite_members(self): + with self.assertQueryCount(employee=82): + shared_article = self.shared_children[0].with_env(self.env) + partners = (self.customer + self.partner_employee_manager + self.partner_employee2).with_env(self.env) + shared_article.invite_members(partners, 'write') + + @users('employee') + @warmup + def test_article_move_to(self): + before_id = self.workspace_children[0].id + with self.assertQueryCount(employee=24): # knowledge: 23 + writable_article = self.workspace_children[1].with_env(self.env) + writable_article.move_to(parent_id=writable_article.parent_id.id, before_article_id=before_id) + + @users('employee') + @warmup + def test_get_user_sorted_articles(self): + with self.assertQueryCount(employee=3): + self.env['knowledge.article'].get_user_sorted_articles('') + +@tagged('knowledge_performance', 'post_install', '-at_install') +class KnowledgePerformancePermissionCase(KnowledgeArticlePermissionsCase): + + @users('employee') + @warmup + def test_user_has_parent_path_access(self): + """We are testing the performance to access the field user_has_parent_path_access in different situations. + The arborescence tested here is the one contained inside Readable Root. + """ + self.assertFalse(self.article_read_contents_children.user_has_access) + + self.article_read_contents_children.sudo()._add_members(self.env.user.partner_id, 'read') + self.assertTrue(self.article_read_contents_children.with_user(self.env.user).user_has_access) + + # Testing the number of queries depending on the number of ancestors => parent_path + # Conclusion: No evolution with a higher number of ancestors + # article_headers[1] => ('TTRPG') + with self.assertQueryCount(employee=2): + self.article_headers[1].with_user(self.env.user).user_has_access_parent_path + + # article_read_contents_children => (Child of 'Secret') + with self.assertQueryCount(employee=2): + self.article_read_contents_children.with_user(self.env.user).user_has_access_parent_path + # article_read_desync => (Child of 'Mansion of Terror') + with self.assertQueryCount(employee=5): + self.article_read_desync[0].with_user(self.env.user).user_has_access_parent_path + + # Testing evolution in query number depending on the number of tested records + # Conclusion: Proportional evolution + # article_read_contents[0] => ('OpenCthulhu') + with self.assertQueryCount(employee=3): + self.article_read_contents[0].with_user(self.env.user).user_has_access_parent_path + + # article_read_contents[1:3] => ('Open Paranoïa'), ('Proprietary RPGs') + with self.assertQueryCount(employee=3): + for article in self.article_read_contents[1:3]: + article.with_user(self.env.user).user_has_access_parent_path + + +@tagged('knowledge_performance', 'post_install', '-at_install') +class KnowledgePerformanceSidebarCase(KnowledgeCommonWData): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.wkspace_grand_children = cls.env['knowledge.article'].create([{ + 'name': 'Workspace Grand-Child', + 'parent_id': cls.workspace_children[0].id, + }] * 2) + + @users('employee') + @warmup + def test_article_tree_panel(self): + with self.assertQueryCount(employee=23): + self.wkspace_grand_children[0].with_user(self.env.user.id).get_sidebar_articles([self.article_shared.id]) + + @users('employee') + @warmup + def test_article_tree_panel_w_favorites(self): + self.env['knowledge.article.favorite'].create([{ + 'user_id': self.env.user.id, + 'article_id': article_id + } for article_id in (self.workspace_children | self.wkspace_grand_children).ids]) + + with self.assertQueryCount(employee=20): + self.wkspace_grand_children[0].with_user(self.env.user.id).get_sidebar_articles([self.article_shared.id]) diff --git a/addons_extensions/knowledge/tests/test_knowledge_security.py b/addons_extensions/knowledge/tests/test_knowledge_security.py new file mode 100644 index 000000000..a0279a11d --- /dev/null +++ b/addons_extensions/knowledge/tests/test_knowledge_security.py @@ -0,0 +1,284 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import exceptions +from odoo.addons.knowledge.tests.common import KnowledgeArticlePermissionsCase +from odoo.addons.mail.tests.common import mail_new_test_user +from odoo.tests.common import tagged, users +from odoo.tools import mute_logger + + +@tagged('knowledge_acl') +class TestKnowledgeSecurity(KnowledgeArticlePermissionsCase): + """ Tests ACLs and low level access on models. Do not test the internals + of permission computation as those are done in another test suite. Here + we rely on them to check the create/read/write/unlink access checks. """ + + @classmethod + def setUpClass(cls): + """ Add some test users for security / groups check """ + super().setUpClass() + + cls.user_erp_manager = mail_new_test_user( + cls.env, + company_id=cls.company_admin.id, + country_id=cls.env.ref('base.be').id, + groups='base.group_erp_manager', + login='user_erp_manager', + name='Emmanuel Erp Manager', + notification_type='inbox', + signature='--\nEmmanuel' + ) + + @mute_logger('odoo.addons.base.models.ir_model', 'odoo.addons.base.models.ir_rule') + @users('user_public') + def test_models_as_public(self): + # ARTICLE + with self.assertRaises(exceptions.AccessError, msg='ACLs: No article access to public'): + self.env['knowledge.article'].search([]) + + # FAVORITES + with self.assertRaises(exceptions.AccessError, msg='ACLs: No favorite access to public'): + self.env['knowledge.article.favorite'].search([]) + + # MEMBERS + with self.assertRaises(exceptions.AccessError, msg='ACLs: No member access to public'): + self.env['knowledge.article.member'].search([]) + + # COVERS + with self.assertRaises(exceptions.AccessError, msg='ACLs: no cover access to public'): + self.env['knowledge.cover'].search([]) + + @mute_logger('odoo.addons.base.models.ir_model', 'odoo.addons.base.models.ir_rule') + @users('portal_test') + def test_models_as_portal(self): + article_root = self.article_roots[0].with_env(self.env) + + # ARTICLES + with self.assertRaises(exceptions.AccessError, + msg="ACLs: No access given to portal"): + article_root.body # access body should trigger acls + + article_shared = self.article_roots[2].with_env(self.env) + with self.assertRaises(exceptions.AccessError, + msg="ACLs: Internal permission 'none', not for portal"): + article_shared.body # access body should trigger acls + + article_accessible = self.article_write_contents[2].with_env(self.env) + self.assertEqual(article_accessible.body, 'Writable Subarticle through inheritance
', + "ACLs: should be accessible due to explicit 'read' member permission") + self.assertTrue(article_accessible.is_user_favorite) + + # FAVORITES + favs = self.env['knowledge.article.favorite'].search([]) + self.assertEqual(len(favs), 1) + self.assertEqual(favs.article_id, article_accessible) + sudo_favorites = self.article_roots.favorite_ids.with_env(self.env) + self.assertEqual(len(sudo_favorites), 2) + with self.assertRaises(exceptions.AccessError, + msg='ACLs: Breaking rule for portal'): + sudo_favorites.mapped('user_id') # access body should trigger acls + + # MEMBERS + my_members = self.env['knowledge.article.member'].search([]) + self.assertEqual(len(my_members), 4) + self.assertEqual( + my_members, ( + self.article_read_contents[0] | + self.article_read_contents[1] | + self.article_write_contents[2] + ).article_member_ids, + msg="Portal can read all members from articles he has access to" + ) + sudo_members = self.article_roots.article_member_ids.with_env(self.env) + with self.assertRaises(exceptions.AccessError, + msg='Breaking rule for portal'): + sudo_members.mapped('partner_id') # access body should trigger acls + + # COVERS + with self.assertRaises(exceptions.AccessError, + msg="ACLs: No cover access to portal"): + self.env['knowledge.cover'].search([]) + + @mute_logger('odoo.models.unlink') + @users('user_erp_manager') + def test_models_as_erp_manager(self): + self.assertTrue(self.env.user.has_group('base.group_erp_manager')) + self.assertFalse(self.env.user.has_group('base.group_system')) + + article_writable = self.article_roots[0].with_env(self.env) + article_writable.body # access body should trigger acls + article_readable = self.article_roots[1].with_env(self.env) + article_readable.body # access body should trigger acls + self.assertTrue(article_readable.user_has_access) + self.assertTrue(article_readable.user_can_read) + self.assertFalse(article_readable.user_has_write_access) + self.assertFalse(article_readable.user_can_write) + + # ARTICLE: CREATE: cannot create a private article for another user + with self.assertRaises(exceptions.AccessError, msg='Erp Managers behave like internal users'): + _other_private = self.env['knowledge.article'].create({ + 'article_member_ids': [(0, 0, { + 'partner_id': self.partner_employee.id, + 'permission': 'write', + })], + 'internal_permission': 'none', + 'name': 'Private for Employee', + }) + + @mute_logger('odoo.models.unlink') + @users('admin') + def test_models_as_system(self): + self.assertTrue(self.env.user.has_group('base.group_system')) + + article_roots = self.article_roots.with_env(self.env) + article_roots.mapped('body') # access body should trigger acls + article_hidden = self.article_read_contents[3].with_env(self.env) + article_hidden.body # access body should trigger acls + article_readable = self.article_roots[1].with_env(self.env) + article_readable.body # access body should trigger acls + self.assertTrue(article_readable.user_has_access) + self.assertTrue(article_readable.user_can_read) + self.assertFalse(article_readable.user_has_write_access) + self.assertTrue(article_readable.user_can_write) + + # ARTICLE: CREATE/READ + # create a private article for another user + other_private = self.env['knowledge.article'].create({ + 'article_member_ids': [(0, 0, { + 'partner_id': self.partner_employee.id, + 'permission': 'write', + })], + 'internal_permission': 'none', + 'name': 'Private for Employee', + }) + self.assertMembers(other_private, 'none', {self.partner_employee: 'write'}) + self.assertEqual(other_private.category, 'private') + self.assertTrue(other_private.user_can_write, 'Can write ACL-like is True, system can do everything') + self.assertFalse(other_private.user_has_write_access, 'Can write based on permission is False but can perform write due to ACLs') + other_private.write({'name': 'Admin can do everything'}) + + # create a child to it + other_private_child = self.env['knowledge.article'].create({ + 'name': 'Child of Private for Employee', + 'parent_id': other_private.id, + }) + self.assertMembers(other_private_child, False, {}) + self.assertEqual(other_private_child.article_member_ids.partner_id, self.env['res.partner']) + self.assertEqual(other_private_child.category, 'private') + self.assertTrue(other_private_child.user_can_write, 'Can write ACL-like is True, system can do everything') + self.assertFalse(other_private_child.user_has_write_access, 'Can write based on permission is False but can perform write due to ACLs') + + # ARTICLE: WRITE + other_private.write({'name': 'Can Update'}) + other_private_child.write({'name': 'Can Also Update'}) + + # FAVORITES: CREATE/READ/UNLINK + other_private_child.action_toggle_favorite() + self.assertTrue(other_private_child.is_user_favorite) + favorite_rec = self.env['knowledge.article.favorite'].search([('article_id', '=', other_private_child.id)]) + favorite_rec.unlink() + self.assertFalse(other_private_child.is_user_favorite) + + # MEMBERS: CREATE/READ/UNLINK + members = other_private.article_member_ids + self.assertEqual(members.partner_id, self.partner_employee) + new_member = self.env['knowledge.article.member'].create({ + 'article_id': other_private.id, + 'partner_id': self.partner_employee2.id, + 'permission': 'read', + }) + members = other_private.article_member_ids + self.assertEqual(members.partner_id, self.partner_employee + self.partner_employee2) + new_member.write({'permission': 'write'}) + members.filtered(lambda m: m.partner_id == self.partner_employee).unlink() + members = other_private.article_member_ids + self.assertEqual(members, new_member) + self.assertEqual(members.partner_id, self.partner_employee2) + + # COVERS + cover = self._create_cover() + cover.write({'attachment_url': '/'}) + self.assertEqual(cover.attachment_url, '/') + cover.unlink() + + @mute_logger('odoo.addons.base.models.ir_model', 'odoo.addons.base.models.ir_rule', 'odoo.models.unlink') + @users('employee') + def test_models_as_user(self): + article_roots = self.article_roots.with_env(self.env) + + # ARTICLES + article_roots.mapped('body') # access body should trigger acls + article_roots[0].write({'name': 'Hacked (or not)'}) + with self.assertRaises(exceptions.AccessError, + msg="ACLs: 'read' internal permission"): + article_roots[1].write({'name': 'Hacked'}) + with self.assertRaises(exceptions.AccessError, + msg="ACLs: 'read' member permission"): + article_roots[2].write({'name': 'Hacked'}) + + article_hidden = self.article_read_contents[3].with_env(self.env) + with self.assertRaises(exceptions.AccessError, + msg="ACLs: 'none' internal permission"): + article_hidden.body # access body should trigger acls + + # FAVORITES + my_favs = self.env['knowledge.article.favorite'].search([]) + self.assertEqual( + my_favs, + self.articles_all.favorite_ids.filtered(lambda f: f.user_id == self.env.user), + 'Favorites: employee should see its own favorites' + ) + my_favs.mapped('user_id') # access body should trigger acls + my_favs.write({'sequence': 0}) + with self.assertRaises(exceptions.AccessError, + msg="ACLs: should not be used to change article/user"): + my_favs[0].write({'article_id': article_roots[0].id}) + with self.assertRaises(exceptions.AccessError, + msg="ACLs: should not be used to change article/user"): + my_favs[0].write({'user_id': self.user_portal.id}) + + # MEMBERS + my_members = self.env['knowledge.article.member'].search([('article_id', 'in', self.article_roots.ids)]) + self.assertEqual(len(my_members), 4) + self.assertEqual( + my_members, + self.article_roots.article_member_ids, + 'Members: employee should memberships of visible ' + ) + # remove employee from Shared root, check they cannot read those members + self.article_roots[2].article_member_ids.filtered(lambda m: m.partner_id == self.partner_employee).unlink() + my_members = self.env['knowledge.article.member'].search([('article_id', 'in', self.article_roots.ids)]) + self.assertEqual(len(my_members), 2) + self.assertEqual( + my_members, + (self.article_roots[1] + self.article_roots[3]).article_member_ids, + 'Members: employee should see its own memberships' + ) + my_members.mapped('partner_id') # access body should trigger acls + with self.assertRaises(exceptions.AccessError, + msg="ACLs: no ACLs for write for user"): + my_members.write({'permission': 'write'}) + + # COVERS + cover = self._create_cover() + cover.write({'attachment_url': '/'}) + self.assertEqual(cover.attachment_url, '/') + cover.unlink() + + @mute_logger('odoo.addons.base.models.ir_model', 'odoo.addons.base.models.ir_rule') + @users('employee') + def test_models_as_user_copy(self): + article_hidden = self.article_read_contents[3].with_env(self.env) + with self.assertRaises(exceptions.AccessError, + msg="ACLs: 'none' internal permission"): + article_hidden.body # access body should trigger acls + + with self.assertRaises(exceptions.AccessError, + msg="ACLs: copy should not allow to access hidden articles"): + _new_article = article_hidden.copy() + + article_root_readonly = self.article_roots[0].with_env(self.env) + with self.assertRaises(exceptions.AccessError, + msg="ACLs: copy should not allow to duplicate other people members"): + _new_article = article_root_readonly.copy() diff --git a/addons_extensions/knowledge/tests/test_res_users.py b/addons_extensions/knowledge/tests/test_res_users.py new file mode 100644 index 000000000..0cca21915 --- /dev/null +++ b/addons_extensions/knowledge/tests/test_res_users.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.knowledge.tests.common import KnowledgeCommon +from odoo.tests.common import tagged + + +@tagged('knowledge_user') +class TestResUsers(KnowledgeCommon): + + def test_onboarding_article(self): + internal_user = self.env['res.users'].with_context(**self._test_context).create({ + 'email': 'hector@example.com', + 'login': 'hector', + 'password': 'hectorhector', + 'name': 'Hector Tue', + 'groups_id': [(4, self.env.ref('base.group_user').id)], + }) + onboarding_article = self.env['knowledge.article'].search( + [('article_member_ids.partner_id', '=', internal_user.partner_id.id)] + ) + self.assertMembers(onboarding_article, 'none', {internal_user.partner_id: 'write'}) + hector_article = onboarding_article.with_user(internal_user) + self.assertTrue(hector_article.is_user_favorite) + self.assertTrue(hector_article.name, f'Welcome {internal_user.name}') + + def test_onboarding_article_skip(self): + portal_user = self.env['res.users'].with_context(**self._test_context).create({ + 'email': 'patrick@example.com', + 'login': 'patrick', + 'password': 'patrickpatrick', + 'name': 'Patrick Hochet', + 'groups_id': [(4, self.env.ref('base.group_portal').id)], + }) + onboarding_article = self.env['knowledge.article'].search( + [('article_member_ids.partner_id', '=', portal_user.partner_id.id)] + ) + self.assertFalse(onboarding_article) + + internal_user = self.env['res.users'].with_context( + knowledge_skip_onboarding_article=True, + **self._test_context + ).create({ + 'email': 'roberta@example.com', + 'login': 'roberta', + 'password': 'robertaroberta', + 'name': 'Roberta Rabiscotée', + 'groups_id': [(4, self.env.ref('base.group_user').id)], + }) + onboarding_article = self.env['knowledge.article'].search( + [('article_member_ids.partner_id', '=', internal_user.partner_id.id)] + ) + self.assertFalse(onboarding_article) diff --git a/addons_extensions/knowledge/views/knowledge_article_favorite_views.xml b/addons_extensions/knowledge/views/knowledge_article_favorite_views.xml new file mode 100644 index 000000000..a69840ab7 --- /dev/null +++ b/addons_extensions/knowledge/views/knowledge_article_favorite_views.xml @@ -0,0 +1,61 @@ + ++ No Favorites yet! +
++ Add articles in your list of favorites by clicking on the next to the article name. +
++ No Members yet! +
+
+ Adding members allows you to share Articles while granting specific access rights
(can write, can read, ...).
+
+ No stage yet! +
++ Add an embed kanban view of article items in the body of an article by using '/kanban' command. +
++ Create an article +
+ Be the first one to unleash the power of Knowledge! +
++ Create an Article Item +
+ Article items are articles that exist inside their parents but are not displayed in the menu. + They can be used to handle lists (Buildings, Tasks, ...). +
++ Create an Article Item +
+ Article items are articles that exist inside their parents but are not displayed in the menu. + They can be used to handle lists (Buildings, Tasks, ...). +
++ Create an Article Item +
+ Article items are articles that exist inside their parents but are not displayed in the menu. + They can be used to handle lists (Buildings, Tasks, ...). +
+