diff --git a/knowledge/__init__.py b/knowledge/__init__.py new file mode 100644 index 0000000..e702d39 --- /dev/null +++ b/knowledge/__init__.py @@ -0,0 +1,26 @@ +# -*- 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 populate +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 _init_private_article_per_user(env): + env['res.users'].search([('partner_share', '=', False)])._generate_tutorial_articles() diff --git a/knowledge/__manifest__.py b/knowledge/__manifest__.py new file mode 100644 index 0000000..8a57f0b --- /dev/null +++ b/knowledge/__manifest__.py @@ -0,0 +1,115 @@ +# -*- 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', + 'mail', + 'portal', + 'web_unsplash', + 'web_hierarchy', + 'web_map', + ], + 'data': [ + 'data/article_templates.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', + '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', + 'knowledge/static/src/scss/knowledge_blocks.scss', + 'knowledge/static/src/xml/**/*', + ('remove', 'knowledge/static/src/xml/knowledge_wysiwyg.xml'), + 'knowledge/static/src/components/**/*', + 'knowledge/static/src/discuss/**/*', + 'knowledge/static/src/emoji_picker/**/*', + 'knowledge/static/src/search_model/**/*', + 'knowledge/static/src/web/**/*', + 'knowledge/static/src/js/knowledge_controller.js', + 'knowledge/static/src/js/knowledge_plugin.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/js/tools/**/*', + 'knowledge/static/src/webclient/**/*', + 'knowledge/static/src/views/**/*', + 'knowledge/static/src/services/**/*', + 'knowledge/static/src/macros/**/*', + ], + "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/scss/knowledge_blocks.scss', + 'knowledge/static/src/js/tools/**/*', + 'knowledge/static/src/js/knowledge_utils.js', + ], + 'web.assets_tests': [ + 'knowledge/static/tests/tours/**/*', + ], + 'web.qunit_suite_tests': [ + 'knowledge/static/tests/knowledge_form_view_tests.js', + 'knowledge/static/tests/form_controller_patch_tests.js', + 'knowledge/static/tests/knowledge_external_embeds_hotkeys.js', + 'knowledge/static/tests/knowledge_form_view_tests.js', + 'knowledge/static/tests/knowledge_behaviors_upgrade_tests.js', + 'knowledge/static/tests/knowledge_wysiwyg_collaboration_tests.js', + ('include', 'knowledge.assets_wysiwyg'), + ], + 'web.tests_assets': [ + 'knowledge/static/tests/mock_services.js', + ], + 'web_editor.backend_assets_wysiwyg': [ + 'knowledge/static/src/xml/knowledge_wysiwyg.xml', + ], + 'knowledge.assets_wysiwyg': [ + 'knowledge/static/src/js/wysiwyg.js', + 'knowledge/static/src/js/knowledge_wysiwyg.js', + 'knowledge/static/src/xml/knowledge_editor.xml', + 'knowledge/static/src/xml/knowledge_article_templates.xml', + 'knowledge/static/src/js/knowledge_clipboard_whitelist.js', + ], + 'knowledge.webclient': [ + ('include', 'web.assets_backend'), + # knowledge webclient overrides + 'knowledge/static/src/portal_webclient/**/*', + 'web/static/src/start.js', + ], + 'knowledge.assets_knowledge_print': [ + 'knowledge/static/src/scss/knowledge_print.scss', + ] + }, +} diff --git a/knowledge/controllers/__init__.py b/knowledge/controllers/__init__.py new file mode 100644 index 0000000..978d386 --- /dev/null +++ b/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/knowledge/controllers/article_thread.py b/knowledge/controllers/article_thread.py new file mode 100644 index 0000000..a36ca70 --- /dev/null +++ b/knowledge/controllers/article_thread.py @@ -0,0 +1,47 @@ +# -*- 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 werkzeug.exceptions import Forbidden + + +class ArticleThreadController(KnowledgeController): + + @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() + def mail_thread_messages(self, thread_model, thread_id, **kwargs): + """Portal users doesn't have access to the mail.message model but we want them to be able to + see the messages from a `knowledge.article.thread` on which they can access, if access rules + applies to them. + So for them, we check if they indeed have access to the article linked to the thread and if + that's the case we sudo the search to return the messages. + """ + if request.env.user._is_portal() and thread_model == 'knowledge.article.thread': + thread = request.env['knowledge.article.thread'].browse(thread_id).exists() + if not thread or not thread.article_id.user_has_access: + raise Forbidden() + domain = [ + ("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 + ] + res = request.env["mail.message"].sudo()._message_fetch(domain, **kwargs) + return {**res, "messages": res["messages"].message_format()} + return super().mail_thread_messages(thread_model, thread_id, **kwargs) diff --git a/knowledge/controllers/knowledge_unsplash.py b/knowledge/controllers/knowledge_unsplash.py new file mode 100644 index 0000000..a0c6584 --- /dev/null +++ b/knowledge/controllers/knowledge_unsplash.py @@ -0,0 +1,67 @@ +import requests +import werkzeug + +from odoo.addons.web_unsplash.controllers import main +from odoo import http +from odoo.exceptions import AccessError +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 + Twitter, + + + YouTube + + + or + Facebook. +
+ + +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.
+ + ++ + 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 ODOO
+
+


This year again, Odoo 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 Odooers 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 + + Odoo 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 mentionned 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-behavior-props]'): + # When encoding the "behavior 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 "behavior props" contains `ref('knowledge.article_template_1')`, + # we replace that string occurence with the id returned by the given + # `ref` function evaluated with the xml_id 'knowledge.article_template_1'. + behavior_props = ast.literal_eval(re.sub( + r'(?\w+\.\w+)\'\)', + transform_xmlid_to_res_id, + element.get('data-behavior-props'))) + element.set('data-behavior-props', + parse.quote(json.dumps(behavior_props), safe="()*!'")) + if 'o_knowledge_behavior_type_article' in element.get('class'): + element.set('href', '/knowledge/article/%s' % (behavior_props.get('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 / _name_search 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: set + """ + ancestor_ids = set() + 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(), + ]), + 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), + ], + 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_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) + ] + if self.env.user._is_internal(): + # Do not fetch articles that the user did not join (articles with + # internal permissions may be set as visible to members only) + root_articles_domain.append(("is_article_visible", "=", True)) + + # 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 + + favorite_articles_ids = self.env['knowledge.article.favorite'].sudo().search( + [("user_id", "=", self.env.user.id), ('is_article_active', '=', True)] + ).article_id.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, + } diff --git a/knowledge/models/knowledge_article_favorite.py b/knowledge/models/knowledge_article_favorite.py new file mode 100644 index 0000000..071ad6f --- /dev/null +++ b/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/knowledge/models/knowledge_article_member.py b/knowledge/models/knowledge_article_member.py new file mode 100644 index 0000000..9e0f2d2 --- /dev/null +++ b/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. + + Ǹote: 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/knowledge/models/knowledge_article_stage.py b/knowledge/models/knowledge_article_stage.py new file mode 100644 index 0000000..affbdae --- /dev/null +++ b/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/knowledge/models/knowledge_article_template_category.py b/knowledge/models/knowledge_article_template_category.py new file mode 100644 index 0000000..7311338 --- /dev/null +++ b/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/knowledge/models/knowledge_article_thread.py b/knowledge/models/knowledge_article_thread.py new file mode 100644 index 0000000..e5ea408 --- /dev/null +++ b/knowledge/models/knowledge_article_thread.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ + + +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' + + 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 + + def toggle_thread(self): + """Toggles the resolution state of the article""" + self.ensure_one() + self.is_resolved = not self.is_resolved + + + +# =========================================================================== +# CRUD METHODS +# =========================================================================== + + @api.model_create_multi + def create(self, vals_list): + return super(KnowledgeArticleThread, self.with_context(mail_create_nolog=True)).create(vals_list) + +# ========================================================================== +# THREAD OVERRIDES +# ========================================================================== + + def message_post(self, **kwargs): + """This function overrides the 'mail.thread' message_post in order to let portal users that + have access to an article to post a message in the thread. + We need to apply this method with sudo for portal users because they do not have access to the + `mail.message` model, which is needed to post the message. + This idea is based on the method `portal_chatter_post` which needs to check access rights in + order to let the portal post in the chatter. + + 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, even with the sudo call. + """ + self.ensure_one() + if self.env.user._is_portal() and self.article_id.user_has_access: + authorized_keys = {'body', 'partner_ids', 'author_id'} + return super(KnowledgeArticleThread, self.sudo()).message_post( + **{key: kwargs.get(key) for key in authorized_keys}, + 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. + """ + + kwargs['msg_vals'] = {**kwargs['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_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/knowledge/models/knowledge_cover.py b/knowledge/models/knowledge_cover.py new file mode 100644 index 0000000..4944c25 --- /dev/null +++ b/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/knowledge/models/res_partner.py b/knowledge/models/res_partner.py new file mode 100644 index 0000000..a2142e9 --- /dev/null +++ b/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/knowledge/models/res_users.py b/knowledge/models/res_users.py new file mode 100644 index 0000000..a75d1d0 --- /dev/null +++ b/knowledge/models/res_users.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, api +from odoo.tools.translate import _lt + + +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 + + welcome = _lt('Welcome %s', user.name) + 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, + }) + + if articles_to_create: + self.env['knowledge.article'].sudo().create(articles_to_create) diff --git a/knowledge/populate/__init__.py b/knowledge/populate/__init__.py new file mode 100644 index 0000000..c81b658 --- /dev/null +++ b/knowledge/populate/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import knowledge_article +from . import tools diff --git a/knowledge/populate/knowledge_article.py b/knowledge/populate/knowledge_article.py new file mode 100644 index 0000000..18e6426 --- /dev/null +++ b/knowledge/populate/knowledge_article.py @@ -0,0 +1,265 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models +from odoo.tools import populate +from odoo.addons.base.models.ir_qweb_fields import nl2br +from odoo.addons.knowledge.populate import tools + + +class KnowledgeArticle(models.Model): + _inherit = 'knowledge.article' + _populate_dependencies = [ + 'res.users', # membership management + ] + # be careful that size only defines the amount of ROOT articles + # there will be a lot more articles in the end, counting children + # see underlying docstring in '_prepare_children_articles' + # manual testing gives around 3000-4000 generated articles for "medium" size + # (giving a bigger size also increases the amount of res.users we have for members config) + # /!\ using "large" can take a very long time as we can't take advantage of the batching + # because of parent / children relationship, everything is committed in one singular batch + _populate_sizes = { + 'small': 5, # will result in ~1.500 + 'medium': 20, # will result in ~5.000 records + 'large': 100, # will result in ~27.000 records + } + + def _populate(self, size): + # create some dummy portal users as res.users populate only creates internal users + self.env['res.users'].create([{ + 'login': 'portal_user_knowledge_%i' % i, + 'email': 'portaluserknowledge%i@example.com' % i, + 'name': 'Portal User %i' % i, + 'groups_id': [ + (6, 0, [self.env.ref('base.group_portal').id]), + ] + } for i in range(self.env['res.users']._populate_sizes[size])]) + + return super()._populate(size) + + def _populate_factories(self, depth=0): + internal_partner_ids = self.env['res.users'].search([ + ('share', '=', False), + ]).partner_id.ids + shared_partner_ids = self.env['res.users'].search([ + ('share', '=', True), + ]).partner_id.ids + + return self._populate_article_factories(depth, internal_partner_ids, shared_partner_ids) + + def _populate_article_factories(self, depth, internal_partner_ids, shared_partner_ids): + random = populate.Random('articles') + if depth == 0: + names = populate.iterate(tools._a_title_root) + elif depth == 1: + names = populate.iterate(tools._a_title_top_level) + elif depth == 2: + names = populate.iterate(tools._a_title_leaf) + else: + names = populate.randomize(tools._a_title_low_level, seed=random.randint(1, 100)) + + return { + ('name', names), + ('body', populate.randomize([ + nl2br('%s' % tuple(tools.a_body_content_lorem_ipsum[:1])), + nl2br('%s %s' % tuple(tools.a_body_content_lorem_ipsum[:2])), + nl2br('%s %s %s' % tuple(tools.a_body_content_lorem_ipsum[:3])), + ])), + ('child_ids', populate.compute(lambda *args, **kwargs: self._prepare_children_articles(depth, internal_partner_ids, shared_partner_ids))), + ('icon', populate.randomize(['🗒️', '🤖', '⭐', '🚀', '🎉', '☕', '🏆', '🛫', '💰', '📫'])), + ('internal_permission', populate.compute(lambda *args, **kwargs: self._generate_internal_permission(depth))), + ('is_locked', populate.randomize([True, False], [0.02, 0.98], seed=random.randint(1, 100))), + ('full_width', populate.randomize([True, False], [0.2, 0.8], seed=random.randint(1, 100))), + ('article_member_ids', populate.compute(lambda random, values, **kwargs: self._prepare_member_ids(depth, values, internal_partner_ids, shared_partner_ids))), + ('is_locked', populate.randomize([True, False], [0.02, 0.98])), + ('full_width', populate.randomize([True, False], [0.2, 0.8])), + # TODO add article items / fill some properties fields + ('favorite_ids', populate.compute(lambda values, *args, **kwargs: self._prepare_favorites(values, internal_partner_ids, shared_partner_ids))), + } + + def _prepare_favorites(self, values, internal_partner_ids, shared_partner_ids): + """In Knowledge we offer the possibility for a user to set articles as favorites. + To handle this in the model we have a table `knowledge.article.favorite` that stores every + favorited article for each user. + + Every article has a chance to be the favorite of a user but it is more likely that user will add + a root as a favorite than a child. + Thus to prepare the favorites we will need some information: + * Will the article be favorited by a user ? + * Which user wants to add the article to its favorite ? + + Of course we do not want a lot of articles being favorited so the initial check will reject many of the attempts, + but if we want to add some favorite_ids to an article then we will check how many users will add the article as + favorite. + This is how we will populate the table.""" + random = populate.Random('favorites') + + article_member_ids = values.get('article_member_ids', False) + internal_permission = values.get('internal_permission', False) + # 2% of the articles can be set as favorites + can_be_favorite = random.choices([True, False], weights=[0.02, 0.98], k=1)[0] and ( + internal_permission or article_member_ids) + if not can_be_favorite: + return [] + + admin_partner_id = self.env.ref('base.user_admin').partner_id.id + + # sampling some partners + internals = random.sample(internal_partner_ids, k=min(random.randint(2, 10), len(internal_partner_ids))) + externals = random.sample(shared_partner_ids, k=min(random.randint(2, 10), len(shared_partner_ids))) + # 50% chance to force admin into the mix (easier for testing) + if admin_partner_id not in internals and random.choices([True, False], weights=[0.5, 0.5], k=1)[0]: + internals.append(admin_partner_id) + + # If there are article members, check their permissions, otherwise, all are favorites + all_partners = internals + externals + favorite_partner_ids, partners_to_check_access = ([], all_partners) if article_member_ids else (all_partners, []) + + for partner_id in partners_to_check_access: + member_access = next( + ( + member[2]['permission'] + for member in article_member_ids + if member[2]['partner_id'] == partner_id + ), + False + ) + + if member_access == 'none': + continue # specified no access for this partner -> pass + + if internal_permission == 'none' and not member_access: + continue # internal permission denies access -> pass + + favorite_partner_ids.append(partner_id) + + # sadly the knowledge.article.favorite model needs user_id, so we have to fetch them + linked_users = self.env['res.users'].search([('partner_id', 'in', favorite_partner_ids)]) + user_per_partner = { + user.partner_id.id: user + for user in linked_users + } + + return [(0, 0, { + 'user_id': user_per_partner[partner_id].id + }) for partner_id in favorite_partner_ids] + + def _prepare_children_articles(self, depth, internal_partner_ids, shared_partner_ids): + """ As knowledge.article is a bit meaningless without a parent / children configuration, + this methods aims to fill-up child_ids recursively. + + As the regular populate only allows to specify the 'total amount of records' we want to + create, and it does not handle parent / children relationship, we apply a specific logic for + children articles. + + The idea is to always have children for root articles and then lower the chances of generating + children as you go into higher 'depth', with a maximum of 5 levels in total. + The amount of children is chosen randomly between 2 and 10. + + The code that generates values re-uses the '_populate_factories' method and increases the depth + every time we loop. """ + random = populate.Random('childrenarticles') + + if depth > 4 or random.randint(1, depth + 1) != depth + 1: + # higher depth means lower chance of having children articles + # depth of 0 -> 100% (randint(1,1) needs to equal 1) + # depth of 1 -> 50% (randint(1,2) needs to equal 2) + # depth of 2 -> 33% (randint(1,2,3) needs to equal 3) + # ... + return [] + + record_count = 0 + create_values = [] + field_generators = self._populate_article_factories(depth + 1, internal_partner_ids, shared_partner_ids) + + generator = populate.chain_factories(field_generators, self._name) + for _i in range(random.randint(2, 10)): + values = next(generator) + values.pop('__complete') + create_values.append((0, 0, values)) + record_count += 1 + + return create_values + + def _generate_internal_permission(self, depth): + random = populate.Random('internalpermission') + if depth != 0: + # we keep it simple and only set custom internal permission to root articles + return False + + category = random.choices(['workspace', 'private'], weights=[0.9, 0.1], k=1)[0] + if category == 'private': + # private articles should be 'none' and handled at members level + return 'none' + + # 80% will be write, 20% read + return random.choices(['write', 'read'], weights=[0.8, 0.2], k=1)[0] + + def _prepare_member_ids(self, depth, values, internal_partner_ids, shared_partner_ids): + random = populate.Random('members') + private_member_values = [] + + admin_partner_id = self.env.ref('base.user_admin').partner_id.id + if values.get('internal_permission') == 'none': + # private article -> keep it simple and assume that people will test with admin user + # there will be one auto-generated private article for every user anyway (so size is already big) + private_member_values = [(0, 0, { + 'partner_id': admin_partner_id, + 'permission': 'write' + })] + + # we tweak the end range, so that the deeper you go, the less chance you have of having + # members configuration + randint = random.randint(1, (depth + 1) * 10) + if randint > 4 and values.get('internal_permission') == 'write': + # 60% base chance of not having member configuration + # unless we don't have a 'write' internal permission, in which case we need members + return private_member_values + + internal_partner_ids = random.sample(internal_partner_ids, k=min(random.randint(2, 10), len(internal_partner_ids))) + members_values = [] + for partner_id in internal_partner_ids: + if partner_id == admin_partner_id and private_member_values: + continue # avoid duplicate member + + # force one write members, otherwise 70% of write, 25% read, 5% none + permission = 'write' if partner_id == internal_partner_ids[0] else random.choices( + ['write', 'read', 'none'], weights=[70, 25, 5], k=1 + )[0] + + members_values.append({ + 'partner_id': partner_id, + 'permission': permission + }) + + internal_member_ids = [ + (0, 0, member_values) + for member_values in members_values + ] + + if randint > 2 or values.get('internal_permission') == 'read': + # 20% base chance of having only internal members + return private_member_values + internal_member_ids + + # 10% base chance of having only external members + shared_partner_ids = random.sample(shared_partner_ids, k=min(random.randint(2, 10), len(shared_partner_ids))) + members_values = [] + for partner_id in shared_partner_ids: + # 90% of write + members_values.append({ + 'partner_id': partner_id, + 'permission': random.choices(['read', 'none'], weights=[0.9, 0.1], k=1)[0] + }) + + shared_member_ids = [ + (0, 0, member_values) + for member_values in members_values + ] + + if randint == 2 and values.get('internal_permission') == 'write': + # 10% base chance of having only external members (only for write internal permission) + return shared_member_ids + + # 10% chance to have a mix of both + return private_member_values + internal_member_ids + shared_member_ids diff --git a/knowledge/populate/tools.py b/knowledge/populate/tools.py new file mode 100644 index 0000000..e9ca0ac --- /dev/null +++ b/knowledge/populate/tools.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +# article +_a_title_root = [ + 'HR', 'Accounting', 'Company', 'Day 2 Day', + 'Branding', 'Business', 'Marketing' +] + +_a_title_top_level = [ + 'FAQ', 'Meetings', 'TODOs', 'Documentation', + 'Ideas', '(To Sort / Remove)' +] + +_a_title_leaf = [ + 'Global explanation', 'In Details', 'I have more Questions?', + 'Going further', 'Closing Words' +] + +_a_title_low_level = [ + 'Friday Meeting Notes', 'Contacts Information', '[TO CLEAN]', + 'Untitled', 'Work In Progress' +] + +# 3 paragraphs to combine into an article body +a_body_content_lorem_ipsum = [""" ++
+
+
+
+
+ Highlight content and use the button to add comments +
+Start typing to continue with an empty page or pick an option below to get started.
+ +
+ Organize your database with custom fields
+ (Text, Selection, ...).
+ Those fields will be available on all articles that share the same parent.
+
Missing Calendar configuration.
+\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: "Article Items", + res_model: 'knowledge.article', + type: 'ir.actions.act_window', + views: [[false, 'kanban']], + view_mode: 'kanban', +}; + +function articleItemsKanbanActionContext (wysiwyg) { + return { + active_id: wysiwyg.options.recordInfo.res_id, + default_parent_id: wysiwyg.options.recordInfo.res_id, + default_is_article_item: true, + }; +} diff --git a/knowledge/static/tests/tours/commands/knowledge_list_command_tour.js b/knowledge/static/tests/tours/commands/knowledge_list_command_tour.js new file mode 100644 index 0000000..62b1114 --- /dev/null +++ b/knowledge/static/tests/tours/commands/knowledge_list_command_tour.js @@ -0,0 +1,79 @@ +/** @odoo-module */ + +import { registry } from "@web/core/registry"; +import { endKnowledgeTour, openCommandBar } from '../knowledge_tour_utils.js'; +import { decodeDataBehaviorProps } from "@knowledge/js/knowledge_utils"; +import { stepUtils } from "@web_tour/tour_service/tour_utils"; + +const testName = "*()!'<>~"; +let behaviorProps; + +registry.category("web_tour.tours").add('knowledge_list_command_tour', { + url: '/web', + test: true, + steps: () => [stepUtils.showAppsMenuItem(), { // open the Knowledge App + trigger: '.o_app[data-menu-xmlid="knowledge.knowledge_menu_root"]', +}, { // open the command bar + trigger: '.odoo-editor-editable > p', + run: function () { + openCommandBar(this.$anchor[0]); + }, +}, { // click on the /list command + trigger: '.oe-powerbox-commandName:contains("Item List")', + run: 'click', +}, { // input a test name for the view + trigger: '.modal-dialog #label', + run: `text ${testName}`, +}, { // choose a name for the embedded view + trigger: '.modal-footer button.btn-primary', + run: 'click' +}, { // scroll to the embedded view to load it + trigger: '.o_knowledge_behavior_type_embedded_view', + run: function () { + this.$anchor[0].scrollIntoView(); + }, +}, { // wait for the list view to be mounted + trigger: '.o_knowledge_behavior_type_embedded_view .o_list_renderer', + run: () => {}, +}, { // verify that the view has the correct name and store data-behavior-props + trigger: '.o_knowledge_embedded_view .o_control_panel .o_breadcrumb .active:contains("*()!\'<>~")', + run: () => { + const embeddedViewElement = document.querySelector('.o_knowledge_behavior_type_embedded_view'); + behaviorProps = decodeDataBehaviorProps(embeddedViewElement.dataset.behaviorProps); + } +}, { // click on rename button + trigger: '.o_knowledge_toolbar button:contains(Rename)', + run: 'click', +}, { // click to validate the modal + trigger: '.modal-footer button.btn-primary', + run: 'click' +}, { // check that the name is the correct one and compare previous data-behavior-props and the new one (should be equivalent) + trigger: '.o_knowledge_embedded_view .o_control_panel .o_breadcrumb .active:contains("*()!\'<>~")', + run: () => { + const embeddedViewElement = document.querySelector('.o_knowledge_behavior_type_embedded_view'); + const newBehaviorProps = decodeDataBehaviorProps(embeddedViewElement.dataset.behaviorProps); + if (JSON.stringify(newBehaviorProps) !== JSON.stringify(behaviorProps)) { + // check that knowledge.article render_embedded_view urllib.parse.quote did + // produce an equivalent data-behavior-props as knowledge_utils encodeDataBehaviorProps encodeURIComponent + throw new Error('data-behavior-props should be semantically the same as before'); + } + } +}, { // click on rename button + trigger: '.o_knowledge_toolbar button:contains(Rename)', + run: 'click', +}, { // rename the view + trigger: '.modal-body input', + run: 'text New Title', +}, { // click to validate the modal + trigger: '.modal-footer button.btn-primary', + run: 'click', +}, { // check that name has been updated + trigger: '.o_knowledge_embedded_view .o_control_panel .o_breadcrumb .active:contains("New Title")', + run: () => {}, +}, { + // reload the article to make sure that the article is saved for readonly tour + trigger: 'a[data-menu-xmlid="knowledge.knowledge_menu_home"]', +}, { // wait for embed to be visible + trigger: '.o_knowledge_behavior_type_embedded_view', +}, ...endKnowledgeTour() +]}); diff --git a/knowledge/static/tests/tours/commands/knowledge_outline_command_tour.js b/knowledge/static/tests/tours/commands/knowledge_outline_command_tour.js new file mode 100644 index 0000000..8a00935 --- /dev/null +++ b/knowledge/static/tests/tours/commands/knowledge_outline_command_tour.js @@ -0,0 +1,28 @@ +/** @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"; + + +registry.category("web_tour.tours").add('knowledge_outline_command_tour', { + url: '/web', + test: true, + steps: () => [stepUtils.showAppsMenuItem(), { + // open the Knowledge App + trigger: '.o_app[data-menu-xmlid="knowledge.knowledge_menu_root"]', +}, { // open the command bar + trigger: '.odoo-editor-editable > p', + run: function () { + openCommandBar(this.$anchor[0]); + }, +}, { // click on the /outline command + trigger: '.oe-powerbox-commandName:contains("Index")', + run: 'click', +}, { // wait for the block to appear in the editor + trigger: '.o_knowledge_behavior_type_articles_structure', +}, { // click on the refresh button + trigger: '.o_knowledge_behavior_type_articles_structure button[title="Update"]', + run: 'click', +}, ...endKnowledgeTour() +]}); diff --git a/knowledge/static/tests/tours/commands/knowledge_search_favorites_tour.js b/knowledge/static/tests/tours/commands/knowledge_search_favorites_tour.js new file mode 100644 index 0000000..56e1133 --- /dev/null +++ b/knowledge/static/tests/tours/commands/knowledge_search_favorites_tour.js @@ -0,0 +1,192 @@ +/** @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"; + +/** + * 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']", + }, { // click on the search menu + trigger: "[role='menuitem']:contains(Search)", + }, { // toggle on the kanban view + trigger: ".o_switch_view.o_kanban", + }, { // wait for the kanban view + trigger: ".o_kanban_renderer", + run: () => {}, + }, { // open action menu dropdown + trigger: ".o_control_panel .o_cp_action_menus button", + }, { // click on the knowledge menu button + trigger: ".o_control_panel .o_cp_action_menus .dropdown-toggle:contains(Knowledge)", + run: function () { + this.$anchor[0].dispatchEvent(new Event("mouseenter")); + }, + }, { // click on insert view in article + trigger: ".o_cp_action_menus span:contains('Insert view in article')", + }, { // embed in article + trigger: `.modal-dialog td.o_field_cell:contains(${article})`, + }]; +}; + +/** + * 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: `.o_knowledge_embedded_view .o_control_panel:contains(${kanban1}) .o_searchview_dropdown_toggler`, + }, { + trigger: ".o_favorite_menu .o_add_favorite", + }, { + trigger: ".o_favorite_menu:contains(Favorites) input[type='text']", + run: "text testFilter", + }, { + // use by default + trigger: ".o_favorite_menu .o-checkbox:contains(Default filter) input", + }, { + trigger: ".o_favorite_menu .o_save_favorite", + }, + stepUtils.toggleHomeMenu(), + { + // open the Knowledge App + trigger: ".o_app[data-menu-xmlid='knowledge.knowledge_menu_root']", + }, { + // check that the search item has been added + trigger: ".o_facet_value", + run: function () { + const items = document.querySelectorAll(".o_facet_value"); + if (items.length !== 1) { + console.error("The search should be applied only on the first view"); + } else if (items[0].innerText !== 'testFilter') { + console.error(`Wrong favorite name: ${items[0].innerText}`); + } + }, + }, { + // 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[0].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"); + } + }, + }]; +}; + +registry.category("web_tour.tours").add("knowledge_items_search_favorites_tour", { + url: "/web", + test: true, + steps: () => [ + stepUtils.showAppsMenuItem(), + { + // open the Knowledge App + trigger: ".o_app[data-menu-xmlid='knowledge.knowledge_menu_root']", + }, + { + trigger: ".o_field_html", + run: function () { + const header = document.querySelector(".o_breadcrumb_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[0]); + }, + }, + { + trigger: ".oe-powerbox-commandName:contains('Item Kanban')", + }, + { + trigger: ".modal-body input.form-control", + run: "text Items 1", + }, + { + trigger: "button:contains('Insert')", + }, + // wait for kanban 1 to be inserted + { + trigger: ".o_knowledge_embedded_view .o_control_panel:contains(Items 1)", + run: () => {}, + }, + // Create the second Kanban + { + trigger: ".odoo-editor-editable > h1", + run: function () { + openCommandBar(this.$anchor[0]); + }, + }, + { + trigger: ".oe-powerbox-commandName:contains('Item Kanban')", + }, + { + trigger: ".modal-body input.form-control", + run: "text Items 2", + }, + { + trigger: "button:contains('Insert')", + }, + // wait for kanban 2 to be inserted + { + trigger: ".o_knowledge_embedded_view .o_control_panel:contains(Items 2)", + run: () => {}, + }, + ...validateFavoriteFiltersSteps("Items 1", "Items 2"), + ...endKnowledgeTour(), + ], +}); + +registry.category("web_tour.tours").add("knowledge_search_favorites_tour", { + url: "/web", + test: true, + steps: () => [stepUtils.showAppsMenuItem(), + // insert a first kanban view + ...embedKnowledgeKanbanViewSteps("Article 1"), + { // wait for embedded view to load and click on rename button + trigger: '.o_knowledge_behavior_type_embedded_view:has(.o_knowledge_embedded_view .o_control_panel:contains(Articles)) .o_knowledge_toolbar button:contains(Rename)', + allowInvisible: true, + }, { // rename the view Kanban 1 + trigger: '.modal-dialog input.form-control', + run: `text Kanban 1`, + }, { // click on rename + trigger: "button:contains('Rename')", + }, { // check the application of the rename + trigger: '.o_knowledge_embedded_view .o_control_panel:contains(Kanban 1)', + run: () => {}, + }, + stepUtils.toggleHomeMenu(), + // insert a second kanban view + ...embedKnowledgeKanbanViewSteps("Article 1"), + { // wait for embedded view to load + trigger: '.o_knowledge_embedded_view .o_control_panel:contains(Articles)', + run: () => {}, + }, + ...validateFavoriteFiltersSteps("Kanban 1", "Articles"), + ...endKnowledgeTour(), + ], +}); diff --git a/knowledge/static/tests/tours/commands/knowledge_table_of_content_command_tour.js b/knowledge/static/tests/tours/commands/knowledge_table_of_content_command_tour.js new file mode 100644 index 0000000..ab1ad06 --- /dev/null +++ b/knowledge/static/tests/tours/commands/knowledge_table_of_content_command_tour.js @@ -0,0 +1,67 @@ +/** @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"; + + +registry.category("web_tour.tours").add('knowledge_table_of_content_command_tour', { + url: '/web', + test: true, + steps: () => [stepUtils.showAppsMenuItem(), { + // open the Knowledge App + trigger: '.o_app[data-menu-xmlid="knowledge.knowledge_menu_root"]', +}, { // open the command bar + trigger: '.odoo-editor-editable > p', + run: function () { + openCommandBar(this.$anchor[0]); + }, +}, { // click on the /toc command + trigger: '.oe-powerbox-commandName:contains("Table Of Content")', + run: 'click', +}, { // wait for the block to appear in the editor + trigger: '.o_knowledge_behavior_type_toc', +}, { // insert a few titles in the editor + trigger: '.odoo-editor-editable > p', + run: function () { + const $anchor = $(this.$anchor[0]); + $anchor.append([ + $('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/knowledge/tests/test_knowledge_article_business.py b/knowledge/tests/test_knowledge_article_business.py new file mode 100644 index 0000000..9a28a76 --- /dev/null +++ b/knowledge/tests/test_knowledge_article_business.py @@ -0,0 +1,1516 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime +from unittest.mock import patch + +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', '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 visiblity we add directly the user as member + self.assertEqual(new.body, f'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(KnowledgeCommonBusinessCase): + """Test the concept of visibility for workspace articles""" + + @users("employee") + def test_visibility(self): + # workspace articles + article = self.article_workspace.with_env(self.env) + + self.assertTrue(article.is_article_visible_by_everyone) + self.assertTrue(article.is_article_visible) + + article.write({'is_article_visible_by_everyone': False}) + self.assertFalse(article.is_article_visible) + + article.action_join() + self.assertMembers(article, 'write', {self.env.user.partner_id: 'write'}) + self.assertTrue(article.is_article_visible) + + employee = article.article_member_ids.filtered( + lambda m: m.partner_id == self.env.user.partner_id) + article._remove_member(employee) + self.assertMembers(article, 'write', {}) + self.assertFalse(article.is_article_visible) + + Articles = self.env['knowledge.article'] + new = Articles.article_create(title="Bloup").with_env(self.env) + + self.assertFalse(new.is_article_visible_by_everyone) + self.assertMembers(new, 'write', {self.env.user.partner_id: 'write'}) + + hidden_articles = Articles.get_user_sorted_articles("", hidden_mode=True) + visible_articles = Articles.get_user_sorted_articles("", hidden_mode=False) + self.assertEqual(len(hidden_articles), 8) + self.assertEqual(len(visible_articles), 4) + + creator_member = new.article_member_ids.filtered(lambda m: m.partner_id.id == self.env.user.partner_id.id) + new._remove_member(creator_member) + hidden_articles = Articles.get_user_sorted_articles("", hidden_mode=True) + visible_articles = Articles.get_user_sorted_articles("", hidden_mode=False) + self.assertEqual(len(hidden_articles), 9) + self.assertEqual(len(visible_articles), 3) + + new.action_join() + hidden_articles = Articles.get_user_sorted_articles("", hidden_mode=True) + visible_articles = Articles.get_user_sorted_articles("", hidden_mode=False) + self.assertEqual(len(hidden_articles), 8) + self.assertEqual(len(visible_articles), 4) + + new.move_to(parent_id=article.id) + hidden_articles = Articles.get_user_sorted_articles("", hidden_mode=True) + visible_articles = Articles.get_user_sorted_articles("", hidden_mode=False) + self.assertEqual(len(hidden_articles), 8) + self.assertEqual(len(visible_articles), 4) # We are still member of the article so it's still visible + + article.write({'is_article_visible_by_everyone': True}) + hidden_articles = Articles.get_user_sorted_articles("", hidden_mode=True) + visible_articles = Articles.get_user_sorted_articles("", hidden_mode=False) + self.assertEqual(len(hidden_articles), 0) + self.assertEqual(len(visible_articles), 12) + + article.write({'is_article_visible_by_everyone': False}) + article.action_join() + hidden_articles = Articles.get_user_sorted_articles("", hidden_mode=True) + visible_articles = Articles.get_user_sorted_articles("", hidden_mode=False) + self.assertEqual(len(hidden_articles), 0) + self.assertEqual(len(visible_articles), 12) + + @users('employee') + def test_user_has_access_parent_path(self): + #Testing user_has_access_parent_path + Articles = self.env['knowledge.article'] + + root = Articles.with_user(self.user_admin).article_create(title="Root") + child = Articles.with_user(self.user_admin).article_create(title="Child", parent_id=root.id) + grandchild = Articles.with_user(self.user_admin).article_create(title="Grandchild", parent_id=child.id) + baby = Articles.with_user(self.user_admin).article_create(title="Baby", parent_id=grandchild.id) + + root_user = root.with_user(self.env.user) + child_user = child.with_user(self.env.user) + grandchild_user = grandchild.with_user(self.env.user) + baby_user = baby.with_user(self.env.user) + + self.assertTrue(baby_user.user_has_access_parent_path) + self.assertTrue(baby.user_has_access_parent_path) + + child._add_members(self.env.user.partner_id, 'none') + grandchild._add_members(self.env.user.partner_id, 'write') + + self.assertMembers(child, False, {self.env.user.partner_id: 'none'}) + self.assertMembers(grandchild, False, {self.env.user.partner_id: 'write'}) + self.assertMembers(root, 'write', {self.user_admin.partner_id: 'write'}) + self.assertTrue(root_user.user_has_access) + self.assertTrue(root.user_has_access) + + self.assertTrue(root_user.user_has_access_parent_path) + self.assertTrue(root.user_has_access_parent_path) + + self.assertFalse(child_user.user_has_access) + self.assertTrue(child.user_has_access) + + self.assertTrue(grandchild_user.user_has_access) + self.assertFalse(grandchild_user.user_has_access_parent_path) + + self.assertTrue(baby.user_has_access_parent_path) + self.assertFalse(baby_user.user_has_access_parent_path) + + with self.assertRaises(exceptions.AccessError): + baby_user.action_join() + + # Other categories, the change of visibility shouldn't affect these articles + @users("employee") + def test_private_articles(self): + # private articles + Articles = self.env['knowledge.article'] + + hidden_articles = Articles.get_user_sorted_articles("", hidden_mode=True) + visible_articles = Articles.get_user_sorted_articles("", hidden_mode=False) + self.assertEqual(len(hidden_articles), 0) + self.assertEqual(len(visible_articles), 11) + + private = Articles.article_create(title="Private", is_private=True) + self.assertEqual(private.category, 'private') + private.write({'is_article_visible_by_everyone': True}) + hidden_articles = Articles.get_user_sorted_articles("", hidden_mode=True) + visible_articles = Articles.get_user_sorted_articles("", hidden_mode=False) + self.assertEqual(len(hidden_articles), 0) + self.assertEqual(len(visible_articles), 12) + + self.assertTrue(private.is_article_visible) + self.assertTrue(private.is_article_visible_by_everyone) + + private.write({'is_article_visible_by_everyone': False}) + + hidden_articles = Articles.get_user_sorted_articles("", hidden_mode=True) + visible_articles = Articles.get_user_sorted_articles("", hidden_mode=False) + self.assertEqual(len(hidden_articles), 0) + self.assertEqual(len(visible_articles), 12) + + self.assertTrue(private.is_article_visible) + + @users("employee") + def test_shared_articles(self): + # shared articles + Articles = self.env['knowledge.article'] + + hidden_articles = Articles.get_user_sorted_articles("", hidden_mode=True) + visible_articles = Articles.get_user_sorted_articles("", hidden_mode=False) + self.assertEqual(len(hidden_articles), 0) + self.assertEqual(len(visible_articles), 11) + + to_invite = Articles.article_create(title="To invite", is_private=True) + to_invite.invite_members(self.partner_employee_manager, 'read') + self.assertEqual(to_invite.category, 'shared') + to_invite.write({'is_article_visible_by_everyone': True}) + hidden_articles = Articles.get_user_sorted_articles("", hidden_mode=True) + visible_articles = Articles.get_user_sorted_articles("", hidden_mode=False) + self.assertEqual(len(hidden_articles), 0) + self.assertEqual(len(visible_articles), 12) + + self.assertTrue(to_invite.is_article_visible) + self.assertTrue(to_invite.is_article_visible_by_everyone) + + to_invite.write({'is_article_visible_by_everyone': False}) + + hidden_articles = Articles.get_user_sorted_articles("", hidden_mode=True) + visible_articles = Articles.get_user_sorted_articles("", hidden_mode=False) + self.assertEqual(len(hidden_articles), 0) + self.assertEqual(len(visible_articles), 12) diff --git a/knowledge/tests/test_knowledge_article_constraints.py b/knowledge/tests/test_knowledge_article_constraints.py new file mode 100644 index 0000000..c3ada2c --- /dev/null +++ b/knowledge/tests/test_knowledge_article_constraints.py @@ -0,0 +1,616 @@ +# -*- 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_childs = 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_childs[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_childs = 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_childs[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. + Anything that is not already a shared root can not be moved as a shared + root. """ + shared_child_article = self.shared_child.with_env(self.env) + workspace_article = self.article_workspace.with_env(self.env) + private_article = self.article_private_employee.with_env(self.env) + + with self.assertRaises(exceptions.ValidationError, + msg='Cannot move a shared child article as a shared root'): + shared_child_article[0].move_to(category='shared') + with self.assertRaises(exceptions.ValidationError, + msg='Cannot move a workspace article 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') + + @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/knowledge/tests/test_knowledge_article_internals.py b/knowledge/tests/test_knowledge_article_internals.py new file mode 100644 index 0000000..712ff9a --- /dev/null +++ b/knowledge/tests/test_knowledge_article_internals.py @@ -0,0 +1,295 @@ +# -*- 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. """ + + @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' + ) + + @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}) + + self.assertSetEqual(article_8._get_ancestor_ids(), {article_2.id, article_4.id}) + self.assertSetEqual((article_8 | article_4)._get_ancestor_ids(), {article_2.id, article_4.id}) + self.assertSetEqual((article_8 | article_11)._get_ancestor_ids(), {article_2.id, article_4.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/knowledge/tests/test_knowledge_article_permissions.py b/knowledge/tests/test_knowledge_article_permissions.py new file mode 100644 index 0000000..9affcc9 --- /dev/null +++ b/knowledge/tests/test_knowledge_article_permissions.py @@ -0,0 +1,776 @@ +# -*- 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']) + + # childs: 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 (move_to, command palette search, ...), 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) + + # Articles found via command palette should not include those articles + command_palette_articles = Article.get_user_sorted_articles('', limit=30) + command_palette_article_ids = [article['id'] for article in command_palette_articles] + self.assertTrue(article_header.id in command_palette_article_ids) + self.assertFalse(private_employee_root.id in command_palette_article_ids) + self.assertFalse(explicit_no_access.id in command_palette_article_ids) + + # 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/knowledge/tests/test_knowledge_article_sequence.py b/knowledge/tests/test_knowledge_article_sequence.py new file mode 100644 index 0000000..e4b24e7 --- /dev/null +++ b/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/knowledge/tests/test_knowledge_article_stage.py b/knowledge/tests/test_knowledge_article_stage.py new file mode 100644 index 0000000..670a6fb --- /dev/null +++ b/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/knowledge/tests/test_knowledge_article_template.py b/knowledge/tests/test_knowledge_article_template.py new file mode 100644 index 0000000..5f950b3 --- /dev/null +++ b/knowledge/tests/test_knowledge_article_template.py @@ -0,0 +1,279 @@ +# -*- 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 = [element for element in fragment.xpath("//*[@data-behavior-props]") \ + if "o_knowledge_behavior_type_embedded_view" in element.get("class")] + + self.assertEqual(len(embedded_views), 1) + behavior_props = json.loads(parse.unquote(embedded_views[0].get("data-behavior-props"))) + self.assertEqual(behavior_props, { + "action_xml_id": "knowledge.knowledge_article_item_action", + "display_name": "Article Items", + "view_type": "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/knowledge/tests/test_knowledge_article_thread.py b/knowledge/tests/test_knowledge_article_thread.py new file mode 100644 index 0000000..a8b29a1 --- /dev/null +++ b/knowledge/tests/test_knowledge_article_thread.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests.common import tagged, HttpCase +from odoo.addons.mail.tests.common import mail_new_test_user + + +@tagged('post_install', '-at_install', 'knowledge', 'knowledge_tour', 'knowledge_comments') +class TestKnowledgeArticleTours(HttpCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.thread_test_user = mail_new_test_user( + cls.env, login='thread_test_user', groups='base.group_user', name='Thread Testing User' + ) + cls.article_to_test = cls.env['knowledge.article'].create([{ + 'name': 'Sepultura', + 'body': 'Lorem ipsum dolor sit amet,
consectetur adipiscing elit
', + 'is_article_visible_by_everyone': True + }]) + + + def _init_body_with_comment(self): + thread = self.env['knowledge.article.thread'].create({ + 'article_id': self.article_to_test.id, + }) + + thread.message_post(body='War for Territory') + + self.article_to_test.write({ + 'body': f'''+ Lorem ipsum dolor sit amet,
consectetur adipiscing elit
''' + }) + return thread + + def test_knowledge_create_thread_tour(self): + self.start_tour('/web', 'knowledge_article_thread_main_tour', login='thread_test_user') + + def test_knowledge_answer_comment_tour(self): + self._init_body_with_comment() + + self.start_tour('/web', 'knowledge_article_thread_answer_comment_tour', login='thread_test_user') + + def test_knowledge_resolve_comment_tour(self): + thread = self._init_body_with_comment() + thread.message_post(body='Refuse/Resist') + + self.start_tour('/web', 'knowledge_article_thread_resolve_comment_tour', login='thread_test_user') + + def test_knowledge_comments_panel_tour(self): + thread = self._init_body_with_comment() + thread.message_post(body='Refuse/Resist') + + self.start_tour('/web', 'knowledge_article_thread_panel_tour', login='thread_test_user') diff --git a/knowledge/tests/test_knowledge_article_thread_permissions.py b/knowledge/tests/test_knowledge_article_thread_permissions.py new file mode 100644 index 0000000..8891a42 --- /dev/null +++ b/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.toggle_thread() + + base_thread = self.workspace_thread.with_env(self.env) + # Access to the article + self.assertFalse(base_thread.is_resolved) + base_thread.toggle_thread() + 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/knowledge/tests/test_knowledge_editor_commands.py b/knowledge/tests/test_knowledge_editor_commands.py new file mode 100644 index 0000000..821b221 --- /dev/null +++ b/knowledge/tests/test_knowledge_editor_commands.py @@ -0,0 +1,131 @@ +# -*- 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, users, HttpCase + + +@tagged('post_install', '-at_install', 'knowledge', 'knowledge_tour') +class TestKnowledgeEditorCommands(HttpCase): + """ + 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.env['knowledge.article'].create({ + 'is_article_visible_by_everyone': True, + 'name': 'EditorCommandsArticle', + 'body': Markup('Lorem ipsum dolor sit amet, consectetur adipisicing elit.
'), + }) + + self.start_tour('/web', '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) + + 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('/web', 'knowledge_main_flow_tour', login='admin', step_delay=100) + + # 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('/web', 'knowledge_pick_emoji_tour', login='admin', step_delay=100) + + 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('/web', '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.env.ref('base.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', step_delay=100) + + self.assertTrue(articles[0].with_user(self.env.ref('base.user_demo').id).is_user_favorite) + self.assertTrue(articles[1].with_user(self.env.ref('base.user_demo').id).is_user_favorite) + self.assertGreater( + articles[0].with_user(self.env.ref('base.user_demo').id).user_favorite_sequence, + articles[1].with_user(self.env.ref('base.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('/web', 'knowledge_properties_tour', login='admin', step_delay=100) + + def test_knowledge_items_search_favorites_tour(self): + """Test search favorites for items view""" + self.env['knowledge.article'].create([{'name': 'Article 1'}]) + self.start_tour('/web', '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'}]) + self.start_tour('/web', '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('/web', 'knowledge_sidebar_tour', login='admin', timeout=100) + + # Check section create button and artice 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, + }) + self.start_tour('/web', '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('/web', 'knowledge_random_cover_tour', login='demo') diff --git a/knowledge/tests/test_knowledge_performance.py b/knowledge/tests/test_knowledge_performance.py new file mode 100644 index 0000000..2334c00 --- /dev/null +++ b/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=59): + 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=25): + _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=22): + _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=24): + _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=52): + _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=16): + 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=9): + 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=15): + 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=86): + 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=29): # knowledge: 28 + 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=9): + 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/knowledge/tests/test_knowledge_security.py b/knowledge/tests/test_knowledge_security.py new file mode 100644 index 0000000..829fafb --- /dev/null +++ b/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 comuptation 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/knowledge/tests/test_res_users.py b/knowledge/tests/test_res_users.py new file mode 100644 index 0000000..0cca219 --- /dev/null +++ b/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/knowledge/views/knowledge_article_favorite_views.xml b/knowledge/views/knowledge_article_favorite_views.xml new file mode 100644 index 0000000..000d54f --- /dev/null +++ b/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 a new article item +
++ Create a new article item +
++ Create a new article item +
+