diff --git a/addons/website/views/website_templates.xml b/addons/website/views/website_templates.xml index 971949ea68e..b6ae7033cc9 100644 --- a/addons/website/views/website_templates.xml +++ b/addons/website/views/website_templates.xml @@ -2538,7 +2538,7 @@ Sitemap: sitemap.xml
-
+
diff --git a/addons/website_forum/__manifest__.py b/addons/website_forum/__manifest__.py index 80019e925d1..e1a6ce0565f 100644 --- a/addons/website_forum/__manifest__.py +++ b/addons/website_forum/__manifest__.py @@ -1,12 +1,13 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. + { 'name': 'Forum', 'category': 'Website/Website', 'sequence': 265, 'summary': 'Manage a forum with FAQ and Q&A', - 'version': '1.1', + 'version': '1.2', 'description': """ Ask questions, get answers, no distractions """, @@ -41,6 +42,7 @@ Ask questions, get answers, no distractions 'views/forum_forum_templates_moderation.xml', 'views/forum_forum_templates_post.xml', 'views/forum_forum_templates_tools.xml', + 'views/forum_templates_mail.xml', 'views/website_profile_templates.xml', 'views/snippets/snippets.xml', diff --git a/addons/website_forum/controllers/website_forum.py b/addons/website_forum/controllers/website_forum.py index 8a5ffe4086d..7466710c848 100644 --- a/addons/website_forum/controllers/website_forum.py +++ b/addons/website_forum/controllers/website_forum.py @@ -1,25 +1,22 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import json +import logging + import lxml import requests -import logging import werkzeug.exceptions import werkzeug.urls import werkzeug.wrappers -from datetime import datetime - -from odoo import http, tools, _ -from odoo.exceptions import AccessError +from odoo import _, http, tools from odoo.addons.http_routing.models.ir_http import slug +from odoo.addons.portal.controllers.portal import _build_url_w_params from odoo.addons.website.models.ir_http import sitemap_qs2dom from odoo.addons.website_profile.controllers.main import WebsiteProfile -from odoo.addons.portal.controllers.portal import _build_url_w_params - -from odoo.exceptions import UserError +from odoo.exceptions import AccessError, UserError from odoo.http import request - +from odoo.osv import expression _logger = logging.getLogger(__name__) @@ -117,30 +114,23 @@ class WebsiteForum(WebsiteProfile): my=my, **post ) - question_count, details, fuzzy_search_term = request.website._search_with_fuzzy("forum_posts_only", search, - limit=page * self._post_per_page, order=sorting, options=options) + question_count, details, fuzzy_search_term = request.website._search_with_fuzzy( + "forum_posts_only", search, limit=page * self._post_per_page, order=sorting, options=options) question_ids = details[0].get('results', Post) question_ids = question_ids[(page - 1) * self._post_per_page:page * self._post_per_page] - if tag: - url = "/forum/%s/tag/%s/questions" % (slug(forum), slug(tag)) - else: - url = "/forum/%s" % slug(forum) + url = f"/forum/{slug(forum)}{f'/tag/{slug(tag)}/questions' if tag else ''}" + url_args = {'sorting': sorting} - url_args = { - 'sorting': sorting - } - if search: - url_args['search'] = search - if filters: - url_args['filters'] = filters - if my: - url_args['my'] = my - pager = tools.lazy(lambda: request.website.pager(url=url, total=question_count, page=page, - step=self._post_per_page, scope=self._post_per_page, - url_args=url_args)) + for name, value in zip(['filters', 'search', 'my'], [filters, search, my]): + if value: + url_args[name] = value - values = self._prepare_user_values(forum=forum, searches=post, header={'ask_hide': not forum.active}) + pager = tools.lazy(lambda: request.website.pager( + url=url, total=question_count, page=page, step=self._post_per_page, + scope=self._post_per_page, url_args=url_args)) + + values = self._prepare_user_values(forum=forum, searches=post) values.update({ 'main_object': tag or forum, 'edit_in_backend': True, @@ -167,6 +157,9 @@ class WebsiteForum(WebsiteProfile): values = self._prepare_user_values(forum=forum, header={'is_guidelines': True, 'is_karma': True}, **post) return request.render("website_forum.faq_karma", values) + # Tags + # -------------------------------------------------- + @http.route('/forum/get_tags', type='http', auth="public", methods=['GET'], website=True, sitemap=False) def tag_read(self, forum_id, query='', limit=25, **post): data = request.env['forum.tag'].search_read( @@ -179,31 +172,63 @@ class WebsiteForum(WebsiteProfile): headers=[("Content-Type", "application/json")] ) - @http.route(['/forum//tag', '/forum//tag/'], type='http', auth="public", website=True, sitemap=False) - def tags(self, forum, tag_char=None, **post): - # build the list of tag first char, with their value as tag_char param Ex : [('All', 'all'), ('C', 'c'), ('G', 'g'), ('Z', z)] - first_char_tag = forum.get_tags_first_char() - first_char_list = [(t, t.lower()) for t in first_char_tag if t.isalnum()] - first_char_list.insert(0, (_('All'), 'all')) + @http.route(['/forum//tag', + '/forum//tag/', + ], type='http', auth="public", website=True, sitemap=False) + def tags(self, forum, tag_char='', filters='all', search='', **post): + """Render a list of tags matching filters and search parameters. - active_char_tag = tag_char and tag_char.lower() or 'all' + :param forum: Forum + :param string tag_char: Only tags starting with a single character `tag_char` + :param filters: One of 'all'|'followed'|'most_used'|'unused'. + Can be combined with `search` and `tag_char`. + :param string search: Search query using "forum_tags_only" `search_type` + :param dict post: additional options passed to `_prepare_user_values` + """ + if not isinstance(tag_char, str) or len(tag_char) > 1 or (tag_char and not tag_char.isalpha()): + # So that further development does not miss this. Users shouldn't see it with normal usage. + raise werkzeug.exceptions.BadRequest(_('Bad "tag_char" value "%(tag_char)s"', tag_char=tag_char)) - # generate domain for searched tags - domain = [('forum_id', '=', forum.id), ('posts_count', '>', 0)] - order_by = 'name' - if active_char_tag and active_char_tag != 'all': - domain.append(('name', '=ilike', tools.escape_psql(active_char_tag) + '%')) - order_by = 'posts_count DESC' - tags = request.env['forum.tag'].search(domain, limit=None, order=order_by) - # prepare values and render template + domain = [('forum_id', '=', forum.id), ('posts_count', '=' if filters == "unused" else '>', 0)] + if filters == 'followed' and not request.env.user._is_public(): + domain = expression.AND([domain, [('message_is_follower', '=', True)]]) + + # Build tags result without using tag_char to build pager, then return tags matching it values = self._prepare_user_values(forum=forum, searches={'tags': True}, **post) + tags = request.env["forum.tag"] + + order = 'posts_count DESC' if tag_char else 'name' + + if search: + values.update(search=search) + search_domain = domain if filters in ('all', 'followed') else None + __, details, __ = request.website._search_with_fuzzy( + 'forum_tags_only', search, limit=None, order=order, options={'forum': forum, 'domain': search_domain}, + ) + tags = details[0].get('results', tags) + + if filters in ('unused', 'most_used'): + filter_tags = forum.tag_most_used_ids if filters == 'most_used' else forum.tag_unused_ids + tags = tags & filter_tags if tags else filter_tags + elif filters in ('all', 'followed'): + if not search: + tags = request.env['forum.tag'].search(domain, limit=None, order=order) + else: + raise werkzeug.exceptions.BadRequest(_('Bad "filters" value "%(filters)s".', filters=filters)) + + first_char_tag = forum._get_tags_first_char(tags=tags) + first_char_list = [(t, t.lower()) for t in first_char_tag if t.isalnum()] + first_char_list.insert(0, (_('All'), '')) + if tag_char: + tags = tags.filtered(lambda t: t.name.startswith((tag_char.lower(), tag_char.upper()))) values.update({ - 'tags': tags, + 'active_char_tag': tag_char.lower(), 'pager_tag_chars': first_char_list, - 'active_char_tag': active_char_tag, + 'search_count': len(tags) if search else None, + 'tags': tags, }) - return request.render("website_forum.tag", values) + return request.render("website_forum.forum_index_tags", values) # Questions # -------------------------------------------------- @@ -248,7 +273,6 @@ class WebsiteForum(WebsiteProfile): 'main_object': question, 'edit_in_backend': True, 'question': question, - 'can_bump': (question.forum_id.allow_bump and not question.child_count and (datetime.today() - question.write_date).days > 9), 'header': {'question_data': True}, 'filters': filters, 'reversed': reversed, @@ -292,7 +316,9 @@ class WebsiteForum(WebsiteProfile): if record.create_uid.id == request.uid: answer = record break - return request.redirect("/forum/%s/post/%s/edit" % (slug(forum), slug(answer))) + else: + raise werkzeug.exceptions.NotFound() + return request.redirect(f'/forum/{slug(forum)}/post/{slug(answer)}/edit') @http.route('/forum//question//close', type='http', auth="user", methods=['POST'], website=True) def question_close(self, forum, question, **post): @@ -321,7 +347,7 @@ class WebsiteForum(WebsiteProfile): user = request.env.user if not user.email or not tools.single_email_re.match(user.email): return request.redirect("/forum/%s/user/%s/edit?email_required=1" % (slug(forum), request.session.uid)) - values = self._prepare_user_values(forum=forum, searches={}, header={'ask_hide': True}, new_question=True) + values = self._prepare_user_values(forum=forum, searches={}, new_question=True) return request.render("website_forum.new_question", values) @http.route(['/forum//new', @@ -345,11 +371,13 @@ class WebsiteForum(WebsiteProfile): 'parent_id': post_parent and post_parent.id or False, 'tag_ids': post_tag_ids }) - return request.redirect("/forum/%s/%s" % (slug(forum), post_parent and slug(post_parent) or new_question.id)) + if post_parent: + post_parent._update_last_activity() + return request.redirect(f'/forum/{slug(forum)}/{slug(post_parent) if post_parent else new_question.id}') @http.route('/forum//post//comment', type='http', auth="user", methods=['POST'], website=True) def post_comment(self, forum, post, **kwargs): - question = post.parent_id if post.parent_id else post + question = post.parent_id or post if kwargs.get('comment') and post.forum_id.id == forum.id: # TDE FIXME: check that post_id is the question or one of its answers body = tools.mail.plaintext2html(kwargs['comment']) @@ -357,12 +385,15 @@ class WebsiteForum(WebsiteProfile): body=body, message_type='comment', subtype_xmlid='mail.mt_comment') - return request.redirect("/forum/%s/%s" % (slug(forum), slug(question))) + question._update_last_activity() + return request.redirect(f'/forum/{slug(forum)}/{slug(question)}') @http.route('/forum//post//toggle_correct', type='json', auth="public", website=True) def post_toggle_correct(self, forum, post, **kwargs): if post.parent_id is False: return request.redirect('/') + if request.uid == post.create_uid.id: + return {'error': 'own_post'} if not request.session.uid: return {'error': 'anonymous_user'} @@ -434,13 +465,6 @@ class WebsiteForum(WebsiteProfile): upvote = True if post.user_vote < 0 else False return post.vote(upvote=upvote) - @http.route('/forum/post/bump', type='json', auth="public", website=True) - def post_bump(self, post_id, **kwarg): - post = request.env['forum.post'].browse(int(post_id)) - if not post.exists() or post.parent_id: - return False - return post.bump() - # Moderation Tools # -------------------------------------------------- @@ -501,13 +525,33 @@ class WebsiteForum(WebsiteProfile): return request.render("website_forum.moderation_queue", values) + @http.route('/forum//closed_posts', type='http', auth="user", website=True) + def closed_posts(self, forum, **kwargs): + if request.env.user.karma < forum.karma_moderate: + raise werkzeug.exceptions.NotFound() + + closed_posts_ids = request.env['forum.post'].search( + [('forum_id', '=', forum.id), ('state', '=', 'close')], + order='write_date DESC, id DESC', + ) + values = self._prepare_user_values(forum=forum) + values.update({ + 'posts_ids': closed_posts_ids, + 'queue_type': 'close', + }) + + return request.render("website_forum.moderation_queue", values) + @http.route('/forum//post//validate', type='http', auth="user", website=True) def post_accept(self, forum, post, **kwargs): - url = "/forum/%s/validation_queue" % (slug(forum)) if post.state == 'flagged': - url = "/forum/%s/flagged_queue" % (slug(forum)) + url = f'/forum/{slug(forum)}/flagged_queue' elif post.state == 'offensive': - url = "/forum/%s/offensive_posts" % (slug(forum)) + url = f'/forum/{slug(forum)}/offensive_posts' + elif post.state == 'close': + url = f'/forum/{slug(forum)}/closed_posts' + else: + url = f'/forum/{slug(forum)}/validation_queue' post.validate() return request.redirect(url) @@ -539,11 +583,10 @@ class WebsiteForum(WebsiteProfile): @http.route('/forum//post//mark_as_offensive', type='http', auth="user", methods=["POST"], website=True) def post_mark_as_offensive(self, forum, post, **kwargs): post.mark_as_offensive(reason_id=int(kwargs.get('reason_id', False))) - url = '' if post.parent_id: - url = "/forum/%s/%s/#answer-%s" % (slug(forum), post.parent_id.id, post.id) + url = f'/forum/{slug(forum)}/{post.parent_id.id}/#answer-{post.id}' else: - url = "/forum/%s/%s" % (slug(forum), slug(post)) + url = f'/forum/{slug(forum)}/{slug(post)}' return request.redirect(url) # User @@ -553,15 +596,15 @@ class WebsiteForum(WebsiteProfile): if partner_id: partner = request.env['res.partner'].sudo().search([('id', '=', partner_id)]) if partner and partner.user_ids: - return request.redirect("/forum/%s/user/%d" % (slug(forum), partner.user_ids[0].id)) - return request.redirect("/forum/%s" % slug(forum)) + return request.redirect(f'/forum/{slug(forum)}/user/{partner.user_ids[0].id}') + return request.redirect('/forum/' + slug(forum)) # Profile # ----------------------------------- @http.route(['/forum//user/'], type='http', auth="public", website=True) def view_user_forum_profile(self, forum, user_id, forum_origin='/forum', **post): - return request.redirect('/profile/user/' + str(user_id) + '?forum_id=' + str(forum.id) + '&forum_origin=' + str(forum_origin)) + return request.redirect(f'/profile/user/{user_id}?forum_id={forum.id}&forum_origin={forum_origin}') def _prepare_user_profile_values(self, user, **post): values = super(WebsiteForum, self)._prepare_user_profile_values(user, **post) @@ -621,8 +664,9 @@ class WebsiteForum(WebsiteProfile): [('favourite_ids', '=', user.id), ('forum_id', 'in', forums.ids), ('parent_id', '=', False)]) # votes which given on users questions and answers. - data = Vote._read_group([('forum_id', 'in', forums.ids), ('recipient_id', '=', user.id)], ['vote'], - aggregates=['__count']) + data = Vote._read_group( + [('forum_id', 'in', forums.ids), ('recipient_id', '=', user.id)], ['vote'], aggregates=['__count'] + ) up_votes, down_votes = 0, 0 for vote, count in data: if vote == '1': @@ -646,10 +690,7 @@ class WebsiteForum(WebsiteProfile): posts_ids = Post.search([('id', 'in', list(posts))]) posts = {x.id: (x.parent_id or x, x.parent_id and x or False) for x in posts_ids} - # TDE CLEANME MASTER: couldn't it be rewritten using a 'menu' key instead of one key for each menu ? - if user == request.env.user: - kwargs['my_profile'] = True - else: + if user != request.env.user: kwargs['users'] = True values = { diff --git a/addons/website_forum/data/forum_forum_data.xml b/addons/website_forum/data/forum_forum_data.xml index 00866b0f7d7..9bdc46a9a11 100644 --- a/addons/website_forum/data/forum_forum_data.xml +++ b/addons/website_forum/data/forum_forum_data.xml @@ -3,6 +3,7 @@ Help + This community is for professionals and enthusiasts of our products and services. Share and discuss the best content and new marketing ideas, build your professional profile and become a better marketer together. diff --git a/addons/website_forum/data/forum_forum_template_faq.xml b/addons/website_forum/data/forum_forum_template_faq.xml index d6b936d4f56..77a3fbdca8b 100644 --- a/addons/website_forum/data/forum_forum_template_faq.xml +++ b/addons/website_forum/data/forum_forum_template_faq.xml @@ -5,15 +5,19 @@ qweb website_forum.faq_accordion -
+ + Guidelines +
-
-
- -
-
+
+
+

+ +

+
+

This community is for professional and enthusiast users, partners and programmers. You can ask questions about:

  • how to install Odoo on a specific infrastructure,
  • @@ -27,12 +31,14 @@
-
- -
-
+
+

+ +

+
+

You should only ask practical, answerable questions based on actual problems that you face. Chatty, open-ended questions diminish the usefulness of this site and push other questions off the front page.

To prevent your question from being flagged and possibly removed, avoid asking subjective questions where …

    @@ -54,28 +60,32 @@
-
- -
-
+
+

+ +

+
+

Answers should not add or expand questions. Instead, either edit the question or add a comment.

Answers should not comment other answers. Instead add a comment on the other answers.

Answers shouldn't just point to other questions.Instead add a comment indicating "Possible duplicate of...". However, it's fine to include links to other questions or answers providing relevant additional information.

-

Answers shouldn't just provide a link a solution. Instead provide the solution description text in your answer, even if it's just a copy/paste. Links are welcome, but should be complementary to answer, referring sources or additional reading.

+

Answers shouldn't just provide a link a solution. Instead provide the solution description text in your answer, even if it's just a copy/paste. Links are welcome, but should be complementary to answer, referring sources or additional reading.

Answers should not start debates This community Q&A is not a discussion group. Please avoid holding debates in your answers as they tend to dilute the essence of questions and answers. For brief discussions please use commenting facility.

When a question or answer is upvoted, the user who posted them will gain some points, which are called "karma points". These points serve as a rough measure of the community trust to him/her. Various moderation tasks are gradually assigned to the users based on those points.

For example, if you ask an interesting question or give a helpful answer, your input will be upvoted. On the other hand if the answer is misleading - it will be downvoted. Each vote in favor will generate 10 points, each vote against will subtract 2 points. There is a limit of 200 points that can be accumulated for a question or answer per day. The table given at the end explains reputation point requirements for each type of moderation task.

-
- -
-
+
+

+ +

+
+

The goal of this site is create a relevant knowledge base that would answer questions related to Odoo.

Therefore questions and answers can be edited like wiki pages by experienced users of this site in order to improve the overall quality of the knowledge base content. Such privileges are granted based on user karma level: you will be able to do the same once your karma gets high enough.

If this approach is not for you, please respect the community.

@@ -89,4 +99,4 @@ - \ No newline at end of file + diff --git a/addons/website_forum/models/forum_forum.py b/addons/website_forum/models/forum_forum.py index 0f1fe1a1a51..81a574af4f6 100644 --- a/addons/website_forum/models/forum_forum.py +++ b/addons/website_forum/models/forum_forum.py @@ -2,10 +2,16 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import textwrap +from collections import defaultdict +from operator import itemgetter + +from markupsafe import Markup from odoo import _, api, fields, models -from odoo.tools.translate import html_translate from odoo.addons.http_routing.models.ir_http import slug +from odoo.tools.translate import html_translate + +MOST_USED_TAGS_COUNT = 5 # Number of tags to track as "most used" to display on frontend class Forum(models.Model): @@ -18,28 +24,30 @@ class Forum(models.Model): 'website.multi.mixin', 'website.searchable.mixin', ] - _order = "sequence" + _order = "sequence, id" + @api.model def _get_default_welcome_message(self): - return """ -
-
-
-
-

Welcome!

-

%(message_intro)s
%(message_post)s

-
- -
-
-
""" % { - 'message_intro': _("This community is for professionals and enthusiasts of our products and services."), - 'message_post': _("Share and discuss the best content and new marketing ideas, build your professional profile and become a better marketer together."), - 'hide_text': _('Hide Intro'), - 'register_text': _('Register')} + return Markup(""" +

%(message_intro)s

+
+

%(message_post)s

+

+ %(register_text)s + +

+
+ """) % { + 'message_intro': _("Welcome!"), + 'message_post': _( + "Share and discuss the best content and new marketing ideas, build your professional profile and become" + " a better marketer together." + ), + 'hide_text': _('Dismiss'), + 'register_text': _('Sign up'), + } # description and use name = fields.Char('Forum Name', required=True, translate=True) @@ -69,17 +77,13 @@ class Forum(models.Model): sanitize_attributes=False, sanitize_form=False) default_order = fields.Selection([ ('create_date desc', 'Newest'), - ('write_date desc', 'Last Updated'), + ('last_activity_date desc', 'Last Updated'), ('vote_count desc', 'Most Voted'), ('relevancy desc', 'Relevance'), ('child_count desc', 'Answered')], - string='Default', required=True, default='write_date desc') + string='Default', required=True, default='last_activity_date desc') relevancy_post_vote = fields.Float('First Relevance Parameter', default=0.8, help="This formula is used in order to sort by relevance. The variable 'votes' represents number of votes for a post, and 'days' is number of days since the post creation") relevancy_time_decay = fields.Float('Second Relevance Parameter', default=1.8) - allow_bump = fields.Boolean('Allow Bump', default=True, - help='Check this box to display a popup for posts older than 10 days ' - 'without any given answer. The popup will offer to share it on social ' - 'networks. When shared, a question is bumped at the top of the forum.') allow_share = fields.Boolean('Sharing Options', default=True, help='After posting the user will be proposed to share its question ' 'or answer on social networks, enabling social network propagation ' @@ -131,6 +135,12 @@ class Forum(models.Model): karma_post = fields.Integer(string='Ask questions without validation', default=100) karma_moderate = fields.Integer(string='Moderate posts', default=1000) has_pending_post = fields.Boolean(string='Has pending post', compute='_compute_has_pending_post') + can_moderate = fields.Boolean(string="Is a moderator", compute="_compute_can_moderate") + + # tags + tag_ids = fields.One2many('forum.tag', 'forum_id', string='Tags') + tag_most_used_ids = fields.One2many('forum.tag', string="Most used tags", compute='_compute_tag_ids_usage') + tag_unused_ids = fields.One2many('forum.tag', string="Unused tags", compute='_compute_tag_ids_usage') @api.depends_context('uid') def _compute_has_pending_post(self): @@ -146,6 +156,41 @@ class Forum(models.Model): pending_forums.has_pending_post = True (self - pending_forums).has_pending_post = False + @api.depends_context('uid') + @api.depends('karma_moderate') + def _compute_can_moderate(self): + for forum in self: + forum.can_moderate = self.env.user.karma >= forum.karma_moderate + + @api.depends('post_ids', 'post_ids.tag_ids', 'post_ids.tag_ids.posts_count', 'tag_ids') + def _compute_tag_ids_usage(self): + forums_without_tags = self.filtered(lambda f: not f.tag_ids) + forums_without_tags.tag_most_used_ids = forums_without_tags.tag_unused_ids = False + forums_with_tags = self - forums_without_tags + if not forums_with_tags: + return + + tags_data = self.env['forum.tag'].search_read( + [('forum_id', 'in', forums_with_tags.ids)], + fields=['id', 'forum_id', 'posts_count'], + order='forum_id, posts_count DESC, name, id', + ) + current_forum_id = tags_data[0]['forum_id'][0] + forum_tags = defaultdict(lambda: {'most_used_ids': [], 'unused_ids': []}) + + for tag_data in tags_data: + tag_id, tag_forum_id, posts_count = itemgetter('id', 'forum_id', 'posts_count')(tag_data) + if tag_forum_id[0] != current_forum_id: + current_forum_id = tag_forum_id[0] + if not posts_count: # Could be 0 or None + forum_tags[current_forum_id]['unused_ids'].append(tag_id) + elif len(forum_tags[current_forum_id]['most_used_ids']) < MOST_USED_TAGS_COUNT: + forum_tags[current_forum_id]['most_used_ids'].append(tag_id) + + for forum in forums_with_tags: + forum.tag_most_used_ids = self.env['forum.tag'].browse(forum_tags[forum.id]['most_used_ids']) + forum.tag_unused_ids = self.env['forum.tag'].browse(forum_tags[forum.id]['unused_ids']) + @api.depends('description') def _compute_teaser(self): for forum in self: @@ -266,10 +311,13 @@ class Forum(models.Model): post_tags.insert(0, [6, 0, existing_keep]) return post_tags - def get_tags_first_char(self): - """ get set of first letter of forum tags """ - tags = self.env['forum.tag'].search([('forum_id', '=', self.id), ('posts_count', '>', 0)]) - return sorted(set([tag.name[0].upper() for tag in tags if len(tag.name)])) + def _get_tags_first_char(self, tags=None): + """Get set of first letter of forum tags. + + :param tags: tags recordset to further filter forum's tags that are also in these tags. + """ + tag_ids = self.tag_ids if tags is None else (self.tag_ids & tags) + return sorted({tag.name[0].upper() for tag in tag_ids if len(tag.name)}) # ---------------------------------------------------------------------- # WEBSITE diff --git a/addons/website_forum/models/forum_post.py b/addons/website_forum/models/forum_post.py index 5e514c89a5a..c5c1e6b33c5 100644 --- a/addons/website_forum/models/forum_post.py +++ b/addons/website_forum/models/forum_post.py @@ -4,13 +4,12 @@ import logging import math import re - from datetime import datetime from odoo import api, fields, models, tools, _ +from odoo.addons.http_routing.models.ir_http import slug, unslug from odoo.exceptions import UserError, ValidationError, AccessError from odoo.tools import sql -from odoo.addons.http_routing.models.ir_http import slug, unslug _logger = logging.getLogger(__name__) @@ -23,7 +22,7 @@ class Post(models.Model): 'website.seo.metadata', 'website.searchable.mixin', ] - _order = "is_correct DESC, vote_count DESC, write_date DESC" + _order = "is_correct DESC, vote_count DESC, last_activity_date DESC" name = fields.Char('Title') forum_id = fields.Many2one('forum.forum', string='Forum', required=True) @@ -48,10 +47,11 @@ class Post(models.Model): create_date = fields.Datetime('Asked on', index=True, readonly=True) create_uid = fields.Many2one('res.users', string='Created by', index=True, readonly=True) write_date = fields.Datetime('Updated on', index=True, readonly=True) - bump_date = fields.Datetime('Bumped on', readonly=True, - help="Technical field allowing to bump a question. Writing on this field will trigger " - "a write on write_date and therefore bump the post. Directly writing on write_date " - "is currently not supported and this field is a workaround.") + last_activity_date = fields.Datetime( + 'Last activity on', readonly=True, required=True, default=fields.Datetime.now, + help="Field to keep track of a post's last activity. Updated whenever it is replied to, " + "or when a comment is added on the post or one of its replies." + ) write_uid = fields.Many2one('res.users', string='Updated by', index=True, readonly=True) relevancy = fields.Float('Relevance', compute="_compute_relevancy", store=True) @@ -271,8 +271,7 @@ class Post(models.Model): raise ValueError('Invalid operator: %s' % (operator,)) if not value: - operator = operator == "=" and '!=' or '=' - value = True + operator = '!=' if operator == '=' else '=' user = self.env.user # Won't impact sitemap, search() in converter is forced as public user @@ -293,7 +292,7 @@ class Post(models.Model): ) """ - op = operator == "=" and "inselect" or "not inselect" + op = 'inselect' if operator == '=' else "not inselect" # don't use param named because orm will add other param (test_active, ...) return [('id', op, (req, (user.id, user.karma, user.id, user.karma, user.id)))] @@ -602,15 +601,6 @@ class Post(models.Model): _logger.info('User %s marked as spams (in batch): %s' % (self.env.uid, spams)) return spams.mark_as_offensive(reason_id) - def bump(self): - """ Bump a question: trigger a write_date by writing on a dummy bump_date - field. One cannot bump a question more than once every 10 days. """ - self.ensure_one() - if self.forum_id.allow_bump and not self.child_ids and (datetime.today() - datetime.strptime(self.write_date, tools.DEFAULT_SERVER_DATETIME_FORMAT)).days > 9: - # write through super to bypass karma; sudo to allow public user to bump any post - return self.sudo().write({'bump_date': fields.Datetime.now()}) - return False - def vote(self, upvote=True): self.ensure_one() Vote = self.env['forum.post.vote'] @@ -658,18 +648,18 @@ class Post(models.Model): return new_message @api.model - def convert_comment_to_answer(self, message_id, default=None): + def convert_comment_to_answer(self, message_id): """ Tool to convert a comment (mail.message) into an answer (forum.post). The original comment is unlinked and a new answer from the comment's author is created. Nothing is done if the comment's author already answered the question. """ - comment = self.env['mail.message'].sudo().browse(message_id) - post = self.browse(comment.res_id) - if not comment.author_id or not comment.author_id.user_ids: # only comment posted by users can be converted + comment_sudo = self.env['mail.message'].sudo().browse(message_id) + post = self.browse(comment_sudo.res_id) + if not comment_sudo.author_id or not comment_sudo.author_id.user_ids: # only comment posted by users can be converted return False # karma-based action check: must check the message's author to know if own / all - is_author = comment.author_id.id == self.env.user.partner_id.id + is_author = comment_sudo.author_id.id == self.env.user.partner_id.id karma_own = post.forum_id.karma_comment_convert_own karma_all = post.forum_id.karma_comment_convert_all karma_convert = is_author and karma_own or karma_all @@ -682,14 +672,14 @@ class Post(models.Model): # check the message's author has not already an answer question = post.parent_id if post.parent_id else post - post_create_uid = comment.author_id.user_ids[0] + post_create_uid = comment_sudo.author_id.user_ids[0] if any(answer.create_uid.id == post_create_uid.id for answer in question.child_ids): return False # create the new post post_values = { 'forum_id': question.forum_id.id, - 'content': comment.body, + 'content': comment_sudo.body, 'parent_id': question.id, 'name': _('Re: %s') % (question.name or ''), } @@ -697,33 +687,40 @@ class Post(models.Model): new_post = self.with_user(post_create_uid).sudo().create(post_values).sudo(False) # delete comment - comment.unlink() + comment_sudo.unlink() return new_post def unlink_comment(self, message_id): + comment_sudo = self.env['mail.message'].sudo().browse(message_id) + if comment_sudo.model != 'forum.post': + return [False] * len(self) + + user_karma = self.env.user.karma result = [] for post in self: - user = self.env.user - comment = self.env['mail.message'].sudo().browse(message_id) - if not comment.model == 'forum.post' or not comment.res_id == post.id: + if comment_sudo.res_id != post.id: result.append(False) continue # karma-based action check: must check the message's author to know if own or all - karma_unlink = ( - comment.author_id.id == user.partner_id.id and - post.forum_id.karma_comment_unlink_own or post.forum_id.karma_comment_unlink_all + karma_required = ( + post.forum_id.karma_comment_unlink_own + if comment_sudo.author_id.id == self.env.user.partner_id.id + else post.forum_id.karma_comment_unlink_all ) - can_unlink = user.karma >= karma_unlink - if not can_unlink: - raise AccessError(_('%d karma required to unlink a comment.', karma_unlink)) - result.append(comment.unlink()) + if user_karma < karma_required: + raise AccessError(_('%d karma required to delete a comment.', karma_required)) + result.append(comment_sudo.unlink()) return result def _set_viewed(self): self.ensure_one() return sql.increment_fields_skiplock(self, 'views') + def _update_last_activity(self): + self.ensure_one() + return self.sudo().write({'last_activity_date': fields.Datetime.now()}) + # ---------------------------------------------------------------------- # MESSAGING # ---------------------------------------------------------------------- diff --git a/addons/website_forum/models/forum_post_vote.py b/addons/website_forum/models/forum_post_vote.py index 5d4a08af538..610a8d6f5f2 100644 --- a/addons/website_forum/models/forum_post_vote.py +++ b/addons/website_forum/models/forum_post_vote.py @@ -63,15 +63,13 @@ class Vote(models.Model): for vote in self: vote._check_general_rights(values) - if 'vote' in values: - if (values['vote'] == '1' or vote.vote == '-1' and values['vote'] == '0'): - upvote = True - elif (values['vote'] == '-1' or vote.vote == '1' and values['vote'] == '0'): - upvote = False + vote_value = values.get('vote') + if vote_value is not None: + upvote = vote.vote == '-1' if vote_value == '0' else vote_value == '1' vote._check_karma_rights(upvote) # karma update - vote._vote_update_karma(vote.vote, values['vote']) + vote._vote_update_karma(vote.vote, vote_value) res = super(Vote, self).write(values) return res @@ -90,7 +88,7 @@ class Vote(models.Model): if self._uid != self.user_id.id: raise UserError(_('It is not allowed to modify someone else\'s vote.')) - def _check_karma_rights(self, upvote=None): + def _check_karma_rights(self, upvote=False): # karma check if upvote and not self.post_id.can_upvote: raise AccessError(_('%d karma required to upvote.', self.post_id.forum_id.karma_upvote)) diff --git a/addons/website_forum/models/forum_tag.py b/addons/website_forum/models/forum_tag.py index 8806e60639f..677c830e1cc 100644 --- a/addons/website_forum/models/forum_tag.py +++ b/addons/website_forum/models/forum_tag.py @@ -2,21 +2,26 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, fields, models, _ +from odoo.addons.http_routing.models.ir_http import slug, unslug from odoo.exceptions import AccessError class Tags(models.Model): _name = "forum.tag" _description = "Forum Tag" - _inherit = ['mail.thread', 'website.seo.metadata'] + _inherit = [ + 'mail.thread', + 'website.searchable.mixin', + 'website.seo.metadata', + ] name = fields.Char('Name', required=True) - forum_id = fields.Many2one('forum.forum', string='Forum', required=True) + forum_id = fields.Many2one('forum.forum', string='Forum', required=True, index=True) post_ids = fields.Many2many( 'forum.post', 'forum_tag_rel', 'forum_tag_id', 'forum_id', string='Posts', domain=[('state', '=', 'active')]) posts_count = fields.Integer('Number of Posts', compute='_compute_posts_count', store=True) - + website_url = fields.Char("Link to questions with the tag", compute='_compute_website_url') _sql_constraints = [ ('name_uniq', 'unique (name, forum_id)', "Tag name already exists!"), ] @@ -26,6 +31,11 @@ class Tags(models.Model): for tag in self: tag.posts_count = len(tag.post_ids) # state filter is in field domain + @api.depends("forum_id", "forum_id.name", "name") + def _compute_website_url(self): + for tag in self: + tag.website_url = f'/forum/{slug(tag.forum_id)}/tag/{slug(tag)}/questions' + @api.model_create_multi def create(self, vals_list): for vals in vals_list: @@ -33,3 +43,30 @@ class Tags(models.Model): if self.env.user.karma < forum.karma_tag_create and not self.env.is_admin(): raise AccessError(_('%d karma required to create a new Tag.', forum.karma_tag_create)) return super(Tags, self.with_context(mail_create_nolog=True, mail_create_nosubscribe=True)).create(vals_list) + + # ---------------------------------------------------------------------- + # WEBSITE + # ---------------------------------------------------------------------- + + @api.model + def _search_get_detail(self, website, order, options): + search_fields = ['name'] + fetch_fields = ['id', 'name', 'website_url'] + mapping = { + 'name': {'name': 'name', 'type': 'text', 'match': True}, + 'website_url': {'name': 'website_url', 'type': 'text', 'truncate': False}, + } + base_domain = [] + if forum := options.get("forum"): + forum_ids = (unslug(forum)[1],) if isinstance(forum, str) else forum.ids + search_domain = options.get("domain") + base_domain = [search_domain if search_domain is not None else [('forum_id', 'in', forum_ids)]] + return { + 'model': 'forum.tag', + 'base_domain': base_domain, + 'search_fields': search_fields, + 'fetch_fields': fetch_fields, + 'mapping': mapping, + 'icon': 'fa-tag', + 'order': ','.join(filter(lambda f: 'is_published' not in f, order.split(','))), + } diff --git a/addons/website_forum/models/ir_attachment.py b/addons/website_forum/models/ir_attachment.py index e518d9ef740..6ba7e4a3dd1 100644 --- a/addons/website_forum/models/ir_attachment.py +++ b/addons/website_forum/models/ir_attachment.py @@ -10,7 +10,7 @@ class Attachment(models.Model): def _can_bypass_rights_on_media_dialog(self, **attachment_data): # Bypass the attachment create ACL and let the user create the image - # attachment if he has write right on the model (the image attachment + # attachment if they have write access to the model (the image attachment # will be bound to this model's record). res_model = attachment_data['res_model'] res_id = attachment_data.get('res_id') diff --git a/addons/website_forum/models/website.py b/addons/website_forum/models/website.py index 106c0021898..8d193db4d55 100644 --- a/addons/website_forum/models/website.py +++ b/addons/website_forum/models/website.py @@ -38,6 +38,8 @@ class Website(models.Model): result.append(self.env['forum.forum']._search_get_detail(self, order, options)) if search_type in ['forums', 'forum_posts_only', 'all']: result.append(self.env['forum.post']._search_get_detail(self, order, options)) + if search_type in ['forums', 'forum_tags_only', 'all']: + result.append(self.env['forum.tag']._search_get_detail(self, order, options)) return result def _update_forum_count(self): diff --git a/addons/website_forum/static/src/img/empty.svg b/addons/website_forum/static/src/img/empty.svg new file mode 100644 index 00000000000..6e371b2ff02 --- /dev/null +++ b/addons/website_forum/static/src/img/empty.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/addons/website_forum/static/src/img/help.jpg b/addons/website_forum/static/src/img/help.jpg new file mode 100644 index 00000000000..8827620cb2e Binary files /dev/null and b/addons/website_forum/static/src/img/help.jpg differ diff --git a/addons/website_forum/static/src/img/tasks.svg b/addons/website_forum/static/src/img/tasks.svg new file mode 100644 index 00000000000..4f5d810c102 --- /dev/null +++ b/addons/website_forum/static/src/img/tasks.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/addons/website_forum/static/src/js/tours/website_forum.js b/addons/website_forum/static/src/js/tours/website_forum.js index 3c1a396de3d..d09371d0690 100644 --- a/addons/website_forum/static/src/js/tours/website_forum.js +++ b/addons/website_forum/static/src/js/tours/website_forum.js @@ -9,7 +9,7 @@ wTourUtils.registerBackendAndFrontendTour("question", { url: '/forum/1', }, [{ - trigger: ".o_forum_ask_btn", + trigger: ".o_wforum_ask_btn", position: "left", content: _t("Create a new post in this forum by clicking on the button."), }, { diff --git a/addons/website_forum/static/src/js/website_forum.js b/addons/website_forum/static/src/js/website_forum.js index 6153f1c6d8b..44cacb871d6 100644 --- a/addons/website_forum/static/src/js/website_forum.js +++ b/addons/website_forum/static/src/js/website_forum.js @@ -8,7 +8,7 @@ import wysiwygLoader from "web_editor.loader"; import publicWidget from "web.public.widget"; import { Markup } from "web.utils"; import session from "web.session"; -import { escape } from "@web/core/utils/strings"; +import { escape, sprintf } from "@web/core/utils/strings"; var qweb = core.qweb; var _t = core._t; @@ -26,11 +26,12 @@ publicWidget.registry.websiteForum = publicWidget.Widget.extend({ 'click .o_wforum_flag_validator': '_onFlagValidatorClick', 'click .o_wforum_flag_mark_as_offensive': '_onFlagMarkAsOffensiveClick', 'click .vote_up:not(.karma_required), .vote_down:not(.karma_required)': '_onVotePostClick', - 'click .o_js_validation_queue a[href*="/validate"]': '_onValidationQueueClick', + 'click .o_wforum_validation_queue a[href*="/validate"]': '_onValidationQueueClick', 'click .o_wforum_validate_toggler:not(.karma_required)': '_onAcceptAnswerClick', 'click .o_wforum_favourite_toggle': '_onFavoriteQuestionClick', 'click .comment_delete': '_onDeleteCommentClick', 'click .js_close_intro': '_onCloseIntroClick', + 'click .answer_collapse': '_onExpandAnswerClick', 'submit .js_wforum_submit_form:has(:not(.karma_required).o_wforum_submit_post)': '_onSubmitForm', }, @@ -162,7 +163,12 @@ publicWidget.registry.websiteForum = publicWidget.Widget.extend({ forcedOffset: $(scrollingElement).innerHeight() - $(replyEl).innerHeight(), }); }); - + document.querySelectorAll('.o_wforum_question, .o_wforum_answer, .o_wforum_post_comment, .o_wforum_last_activity') + .forEach((post) => { + post.querySelector('.o_wforum_relative_datetime').textContent = luxon.DateTime + .fromSQL(post.dataset.lastActivity, {zone: 'utc'}) + .toRelative(); + }); return this._super.apply(this, arguments); }, @@ -225,6 +231,21 @@ publicWidget.registry.websiteForum = publicWidget.Widget.extend({ }, 0); } }, + /** + * @private + * @param {Event} ev + */ + _onExpandAnswerClick: function (ev) { + const expandableWindow = ev.currentTarget; + if (ev.target.matches('.o_wforum_expand_toggle')) { + expandableWindow.classList.toggle('o_expand') + expandableWindow.classList.toggle('min-vh-100'); + expandableWindow.classList.toggle('w-lg-50'); + } else if (ev.target.matches('.o_wforum_discard_btn')){ + expandableWindow.classList.remove('o_expand', 'min-vh-100'); + expandableWindow.classList.add('w-lg-50'); + } + }, /** * @private * @param {Event} ev @@ -234,30 +255,26 @@ publicWidget.registry.websiteForum = publicWidget.Widget.extend({ if (!karma) { return; } - ev.preventDefault(); - const forumID = parseInt(document.getElementById('wrapwrap').dataset.forum_id); - const notifOptions = { + if (session.is_website_user) { + this._displayAccessDeniedNotification( + Markup`

${sprintf(_t('Oh no! Please sign in to vote'), "/web/login")}

` + ); + return; + } + const forumId = parseInt(document.getElementById('wrapwrap').dataset.forum_id); + const additionalInfoWithForumID = forumId + ? Markup`
+ + ${_t("Read the guidelines to know how to gain karma.")} + ` + : ""; + this.displayNotification({ type: "warning", sticky: false, - }; - if (session.is_website_user) { - notifOptions.title = _t("Access Denied"); - notifOptions.message = _t("Sorry you must be logged in to perform this action"); - } else { - notifOptions.title = _t("Karma Error"); - // FIXME this translation is bad, the number should be part of the - // translation, to fix in the appropriate version - notifOptions.message = `${karma} ${_t("karma is required to perform this action. ")}`; - if (forumID) { - const linkLabel = _t("Read the guidelines to know how to gain karma."); - notifOptions.message = Markup` - ${notifOptions.message}
- ${linkLabel} - `; - } - } - this.displayNotification(notifOptions); + title: _t("Karma Error"), + message: Markup`${karma} ${_t("karma is required to perform this action. ")}${additionalInfoWithForumID}` + }); }, /** * @private @@ -305,20 +322,14 @@ publicWidget.registry.websiteForum = publicWidget.Widget.extend({ route: elem.dataset.href || (elem.getAttribute('href') !== '#' && elem.getAttribute('href')) || elem.closest('form').getAttribute('action'), }).then(data => { if (data.error) { - var message; - if (data.error === 'anonymous_user') { - message = _t("Sorry you must be logged to flag a post"); - } else if (data.error === 'post_already_flagged') { - message = _t("This post is already flagged"); - } else if (data.error === 'post_non_flaggable') { - message = _t("This post can not be flagged"); - } - this.displayNotification({ - message: message, - title: _t("Access Denied"), - sticky: false, - type: "warning", - }); + const message = data.error === 'anonymous_user' + ? _t("Sorry you must be logged to flag a post") + : data.error === 'post_already_flagged' + ? _t("This post is already flagged") + : data.error === 'post_non_flaggable' + ? _t("This post can not be flagged") + : data.error; + this._displayAccessDeniedNotification(message); } else if (data.success) { const child = elem.firstElementChild; if (data.success === 'post_flagged_moderator') { @@ -327,6 +338,7 @@ publicWidget.registry.websiteForum = publicWidget.Widget.extend({ elem.prepend(child); if (countFlaggedPosts) { countFlaggedPosts.classList.remove('bg-light'); + countFlaggedPosts.classList.remove('d-none'); countFlaggedPosts.classList.add('bg-danger'); countFlaggedPosts.innerText = parseInt(countFlaggedPosts.innerText, 10) + 1; } @@ -352,18 +364,12 @@ publicWidget.registry.websiteForum = publicWidget.Widget.extend({ route: $btn.data('href'), }).then(data => { if (data.error) { - var message; - if (data.error === 'own_post') { - message = _t('Sorry, you cannot vote for your own posts'); - } else if (data.error === 'anonymous_user') { - message = _t('Sorry you must be logged to vote'); - } - this.displayNotification({ - message: message, - title: _t("Access Denied"), - sticky: false, - type: "warning", - }); + const message = data.error === 'own_post' + ? _t('Sorry, you cannot vote for your own posts') + : data.error === 'anonymous_user' + ? Markup`

${sprintf(_t('Oh no! Please sign in to vote'), "/web/login")}

` + : data.error; + this._displayAccessDeniedNotification(message); } else { var $container = $btn.closest('.vote'); var $items = $container.children(); @@ -401,76 +407,99 @@ publicWidget.registry.websiteForum = publicWidget.Widget.extend({ }); }, /** + * Call the route to moderate/validate the post, then hide the validated post + * and decrement the count in the appropriate queue badge of the sidebar on success. + * * @private * @param {Event} ev */ - _onValidationQueueClick: function (ev) { + _onValidationQueueClick: async function (ev) { ev.preventDefault(); - var $link = $(ev.currentTarget); - $link.parents('.post_to_validate').hide(); - $.get($link.attr('href')).then(() => { - var left = $('.o_js_validation_queue:visible').length; - var type = $('h2.o_page_header a.active').data('type'); - $('#count_post').text(left); - $('#moderation_tools a[href*="/' + type + '_"]').find('strong').text(left); - if (!left) { - this.$('.o_caught_up_alert').removeClass('d-none'); - } - }, function () { - $link.parents('.o_js_validation_queue > div').addClass('bg-danger text-white').css('background-color', '#FAA'); - $link.parents('.post_to_validate').show(); - }); + const approvalLink = ev.currentTarget; + const postBeingValidated = this._findParent(approvalLink, '.post_to_validate'); + if (!postBeingValidated) { + return; + } + postBeingValidated.classList.add('d-none'); + let ok; + try { + ok = (await fetch(approvalLink.href)).ok; + } catch { + // Calling the endpoint like this returns an HTML page. As we can't + // extract the error message from that, we disregard it and simply + // restore the post's visibility. This __should__ be improved. + } + if (!ok) { + postBeingValidated.classList.remove('d-none'); + return; + } + const nbLeftInQueue = Array.from(document.querySelectorAll('.post_to_validate')) + .filter(e => window.getComputedStyle(e).display !== 'none') + .length; + const queueType = document.querySelector('#queue_type').dataset.queueType; + const queueCountBadge = document.querySelector(`#count_posts_queue_${queueType}`); + queueCountBadge.innerText = nbLeftInQueue; + if (!nbLeftInQueue) { + document.querySelector('.o_caught_up_alert').classList.remove('d-none'); + queueCountBadge.classList.add('d-none'); + } + }, + _findParent: function (el, selector) { + while (el.parentElement && !el.matches(selector)) { + el = el.parentElement; + } + return el.matches(selector) ? el : null; }, /** * @private * @param {Event} ev */ - _onAcceptAnswerClick: function (ev) { + _onAcceptAnswerClick: async function (ev) { ev.preventDefault(); - var $link = $(ev.currentTarget); - var target = $link.data('target'); - - this._rpc({ - route: $link.data('href'), - }).then(data => { - if (data.error) { - if (data.error === 'anonymous_user') { - var message = _t("Sorry, anonymous users cannot choose correct answer."); - } - this.displayNotification({ - message: message, - title: _t("Access Denied"), - sticky: false, - type: "warning", - }); - } else { - this.$('.forum_answer').toArray().forEach((answer) => { - var $answer = $(answer); - var isCorrect = $answer.is(target) ? data : false; - var $toggler = $answer.find('.o_wforum_validate_toggler'); - var newHelper = isCorrect ? $toggler.data('helper-decline') : $toggler.data('helper-accept'); - - $answer.toggleClass('o_wforum_answer_correct', isCorrect); - $toggler.tooltip('dispose') - .attr('data-bs-original-title', newHelper) - .tooltip({delay: 0}); - }); - } - }); + const link = ev.currentTarget; + const target = link.dataset.target; + const data = await this._rpc({ route: link.dataset.href }); + if (data.error) { + const message = data.error === 'anonymous_user' + ? _t('Sorry, anonymous users cannot choose correct answers.') + : data.error === 'own_post' + ? _t('Sorry, you cannot vote for your own posts') + : data.error; + this._displayAccessDeniedNotification(message); + return; + } + for (const answer of document.querySelectorAll('.o_wforum_answer')) { + const isCorrect = answer.matches(target) ? data : false; + const toggler = answer.querySelector('.o_wforum_validate_toggler'); + toggler.setAttribute('data-bs-original-title', isCorrect ? toggler.dataset.helperDecline : toggler.dataset.helperAccept); + const styleForCorrect = isCorrect ? answer.classList.add : answer.classList.remove; + const styleForIncorrect = isCorrect ? answer.classList.remove : answer.classList.add; + styleForCorrect.call(answer.classList, 'o_wforum_answer_correct', 'my-2', 'mx-n3', 'mx-lg-n2', 'mx-xl-n3', 'py-3', 'px-3', 'px-lg-2', 'px-xl-3'); + styleForIncorrect.call(toggler.classList, 'opacity-50'); + const answerBorder = answer.querySelector('div .border-start'); + styleForCorrect.call(answerBorder.classList, 'border-success'); + const togglerIcon = toggler.querySelector('.fa'); + styleForCorrect.call(togglerIcon.classList, 'fa-check-circle', 'text-success'); + styleForIncorrect.call(togglerIcon.classList, 'fa-check-circle-o'); + const correctBadge = answer.querySelector('.o_wforum_answer_correct_badge'); + styleForCorrect.call(correctBadge.classList, 'd-inline'); + styleForIncorrect.call(correctBadge.classList, 'd-none'); + } }, /** * @private * @param {Event} ev */ - _onFavoriteQuestionClick: function (ev) { + _onFavoriteQuestionClick: async function (ev) { ev.preventDefault(); - var $link = $(ev.currentTarget); - this._rpc({ - route: $link.data('href'), - }).then(function (data) { - $link.toggleClass('o_wforum_gold fa-star', data) - .toggleClass('fa-star-o text-muted', !data); - }); + const link = ev.currentTarget; + const data = await this._rpc({ route: link.dataset.href }); + link.classList.toggle('opacity-50', !data); + link.classList.toggle('opacity-100-hover', !data); + const link_icon = link.querySelector('.fa'); + link_icon.classList.toggle('fa-star-o', !data); + link_icon.classList.toggle('o_wforum_gold', data) + link_icon.classList.toggle('fa-star', data) }, /** * @private @@ -516,16 +545,16 @@ publicWidget.registry.websiteForum = publicWidget.Widget.extend({ method: currentTarget.dataset.action, args: [parseInt(currentTarget.dataset.postId)], }); - currentTarget.parentElement.querySelectorAll('.flag_validator').forEach((element) => element.classList.toggle('d-none')); + this._findParent(currentTarget, '.o_wforum_flag_alert')?.classList.toggle('d-none'); const flaggedButton = currentTarget.parentElement.firstElementChild, child = flaggedButton.firstElementChild, - countFlaggedPosts = this.el.querySelector('#count_flagged_posts'), + countFlaggedPosts = this.el.querySelector('#count_posts_queue_flagged'), count = parseInt(countFlaggedPosts.innerText, 10) - 1; flaggedButton.innerText = _t(' Flag'); flaggedButton.prepend(child); if (count === 0) { - countFlaggedPosts.classList.add("bg-light"); + countFlaggedPosts.classList.add('bg-light'); } countFlaggedPosts.innerText = count; }, @@ -545,12 +574,20 @@ publicWidget.registry.websiteForum = publicWidget.Widget.extend({ renderFooter: false, }).open(); dialog.opened().then(() => { - dialog.$(".btn-light:contains('Discard')").click((ev) => { + dialog.$(".btn-link:contains('Discard')").click((ev) => { ev.preventDefault(); dialog.close(); }); }); }, + _displayAccessDeniedNotification(message) { + this.displayNotification({ + message: message, + title: _t('Access Denied'), + sticky: false, + type: 'warning', + }); + } }); publicWidget.registry.websiteForumSpam = publicWidget.Widget.extend({ diff --git a/addons/website_forum/static/src/js/website_forum.share.js b/addons/website_forum/static/src/js/website_forum.share.js index 6b52c29dd54..33aa5e7a19d 100644 --- a/addons/website_forum/static/src/js/website_forum.share.js +++ b/addons/website_forum/static/src/js/website_forum.share.js @@ -34,13 +34,6 @@ var ForumShare = publicWidget.registry.socialShare.extend({ // Private //-------------------------------------------------------------------------- - /** - * @private - */ - _bindSocialEvent: function () { - this._super.apply(this, arguments); - $('.oe_share_bump').click($.proxy(this._postBump, this)); - }, /** * @private */ @@ -59,17 +52,6 @@ var ForumShare = publicWidget.registry.socialShare.extend({ $('#oe_social_share_modal').modal('show'); } }, - /** - * @private - */ - _postBump: function () { - this._rpc({ // FIXME - route: '/forum/post/bump', - params: { - post_id: this.element.data('id'), - }, - }); - }, }); publicWidget.registry.websiteForumShare = publicWidget.Widget.extend({ @@ -85,11 +67,6 @@ publicWidget.registry.websiteForumShare = publicWidget.Widget.extend({ (new ForumShare(this, false, socialData.targetType)).attachTo($(document.body)); sessionStorage.removeItem('social_share'); } - // Display an alert if post has no reply and is older than 10 days - var $questionContainer = $('.oe_js_bump'); - if ($questionContainer.length) { - new ForumShare(this, false, 'social-alert').attachTo($questionContainer); - } return this._super.apply(this, arguments); }, diff --git a/addons/website_forum/static/src/scss/website_forum.scss b/addons/website_forum/static/src/scss/website_forum.scss index 5b9639e17b8..124de84a628 100644 --- a/addons/website_forum/static/src/scss/website_forum.scss +++ b/addons/website_forum/static/src/scss/website_forum.scss @@ -1,42 +1,103 @@ $gold: #eca801; -$silver: #cccccc; -$bronze: #eea91e; +$silver: #acacac; +$bronze: #ba651f; +$-forum-sidebar-width: map-get($container-max-widths, sm) / 2; +.website_forum, .o_wprofile_body { + --o-avatar-height: 1.875rem; + + .o_wforum_avatar { + width: var(--o-avatar-height); + height: var(--o-avatar-height); + } + + .o_wforum_author_box { + &.o_show_info { + line-height: 1.2; + } + + &.o_compact { + --o-avatar-height: 1.4rem; + line-height: 1; + } + } +} .website_forum { - .o_forum_ask_btn { - @include media-breakpoint-up(md) { - box-shadow: $box-shadow; - width: 200px; + --o-post-header-height: var(--o-avatar-height); + + .o_wforum_wrapper { + @include media-breakpoint-up(xl) { + grid-template-columns: $-forum-sidebar-width minmax(0, auto); + } + + @include media-breakpoint-up(xxl) { + max-width: 100%; } } - .o_wforum_nav .nav-link { - @include o-hover-text-color($body-color, $link-color); - line-height: 1; + .o_wforum_content_wrapper { + @include media-breakpoint-up(xxl) { + margin-left: -$-forum-sidebar-width; - .fa { - opacity: 0.5; - } - - &:hover, &.active { - .fa { - opacity: 1; + > div { + max-width: map-get($grid-breakpoints, xl) - $-forum-sidebar-width - 100px; + @include make-container(); } } + } - &.active { - background-color: rgba(map-get($theme-colors, 'info'), 0.1); - color: darken(map-get($theme-colors, 'info'), 15%)!important; + aside.o_wforum_sidebar { + min-width: $-forum-sidebar-width; + + .btn.w-100:hover { + background-color: darken(map-get($o-color-palette, 'o-color-3'), 4%); + } + } + + .o_wforum_table { + --o-avatar-height: 2.25rem; + + img.o_wforum_avatar { + border: 2px solid var(--o-color-4); } - img.o_forum_avatar { - width: 30px; - height: 30px; + .o_wforum_reply_count, .o_wforum_view_count, .o_wforum_last_activity { + width: 100px; } + + &> :not(caption) > * > * { + @include media-breakpoint-down(lg) { + padding-left: 0; + padding-right: $table-cell-padding-x-sm; + } + + } + + tbody { + @include media-breakpoint-down(lg) { + border-top: 0; + } + + tr:hover { + @if ($body-bg and color-contrast($body-bg) != $color-contrast-dark) { + background-color: lighten($body-bg, 10%); + } @else { + background-color: darken($body-bg, 2%); + } + } + } + } + + .o_wforum_index_entry_title a:visited span { + color: lighten($body-color, 50%); + } + + .btn[aria-expanded="true"] { + opacity: 1 !important; } // Single Post - .o_wforum_post, .note-editable.panel-body { + .o_wforum_post, .note-editable { word-wrap: break-word; pre { @@ -49,22 +110,61 @@ $bronze: #eea91e; blockquote { position: relative; - padding-left: 1em; border-left: .25em solid map-get($grays, '500'); + padding-left: 1em; color: map-get($grays, '600'); } + } - #post_reply { - img.o_forum_avatar { - width: 24px; - height: 24px; + #post_reply { + z-index: $zindex-modal; + min-height: 0; + max-height: 0; + border-radius: $border-radius $border-radius 0 0; + transition: max-#{$transition-collapse}, min-#{$transition-collapse}, #{$transition-collapse}, #{$transition-collapse-width}; + + &.show { + max-height: 100%; + + &.o_expand { + .o_wforum_expand_toggle { + content:'\f066'; + } + + .odoo-editor-editable { + transition: $transition-collapse; + flex-grow: 1; + } + + form.js_website_submit_form { + height: calc(100vh - 100px); + } + } + + .o_wysiwyg_textarea_wrapper { + display: flex; + flex-flow: column; + flex-grow: 1; + + .odoo-editor { + height: 200px !important; + display: flex; + flex-grow: 1; + flex-flow: column; + + .note-editable { + flex-grow: 1; + } + } } } } - .o_wforum_readable { - max-width: 700px; + .o_wforum_post_header, .o_wforum_answer_header { + min-height: var(--o-post-header-height); + } + .o_wforum_readable { p { margin-bottom: 0.5rem; } @@ -99,61 +199,25 @@ $bronze: #eea91e; } } - .o_wforum_author_box { - &.o_show_info { - line-height: 1.2; - img { - width: 2em; - height: 2em; - } - } - - &.o_compact { - line-height: 1; - - img { - width: 1.4em; - height: 1.4em; - } - } - - } - - .forum_answer { - .o_wforum_answer_correct_badge { - display: none; - } - - .o_wforum_author_box_check { - width: 1em; - height: 1em; - display: none; - top: 0; - right: -3px; - box-shadow: 0 0 0 2px white; - line-height: .8; - } - - .o_wforum_validate_toggler { - @include o-hover-text-color(map-get($grays, '400'), lighten(map-get($theme-colors, 'success'), 20%)); - } + .o_wforum_answer { + transition: $transition-base; &.o_wforum_answer_correct { - .o_wforum_answer_correct_badge, .o_wforum_author_box .o_wforum_author_box_check { - display: inline; + // This hack is to have either a light or dark success color + // depending on the background color's contrast + $-cc1-bg: o-color(map-get($o-color-palette, o-cc1-bg)); + $-isBright: color-contrast($-cc1-bg) == $color-contrast-dark; + + @if ($-isBright) { + background-color: mix($color-contrast-light, $success, 90%); + } @else { + $-success-light: lighten(saturate($success, 70), 30); + border: 1px solid $-success-light; + background-color: mix($color-contrast-dark, $-success-light, 80%); } - .o_wforum_validate_toggler { - @include o-hover-text-color(map-get($theme-colors, 'success'), map-get($theme-colors, 'warning')); - - &:hover .fa.fa-check:before { - content: '\f00d'; - } - } - - .o_wforum_answer_header .o_wforum_author_pic { - border: 2px solid $success; - padding: 3px; + .o_wforum_author_pic img { + box-shadow: 0 0 0 2px $success; } } } @@ -177,7 +241,6 @@ $bronze: #eea91e; .website_forum, .o_wforum_profile_tab { .vote { .vote_count { - line-height: 1; animation-play-state: paused; &.o_forum_vote_animate { @@ -185,62 +248,13 @@ $bronze: #eea91e; } } - .vote_down, .vote_up { - line-height: 0.5; - font-size: 1.1em; - @include o-hover-text-color(rgba($text-muted, 0.5), map-get($theme-colors, 'success')); + .vote_up { + @include o-hover-text-color(inherit, $success); } .vote_down { - @include o-hover-text-color(rgba($text-muted, 0.5), map-get($theme-colors, 'danger')); + @include o-hover-text-color(inherit, $danger); } - - &.o_wforum_vote_vertical { - @include media-breakpoint-up(sm) { - font-size: 1.2em; - } - } - } -} - -.o_js_forum_tag_follow { - position: relative; - - .badge { - font-size: 100%; - } - - .o_forum_tag_follow_box { - @include o-position-absolute(100%, auto, auto, 0); - display: none; - z-index: 1; - - .card { - padding: ($grid-gutter-width*0.5) - 1px; - } - } -} - -.o_profile_main { - overflow: hidden; -} - -img.o_forum_avatar { - width: 40px; - height: 40px; - object-fit: cover; -} - -img.o_forum_avatar_big { - width: 75px; - height: 75px; - object-fit: cover; -} - -.o_wprofile_email_validation_container { - img.o_forum_avatar { - width: 16px; - height: 16px; } } @@ -250,6 +264,7 @@ img.o_forum_avatar_big { display: flex; align-items: center; } + span[data-oe-model="res.country"] { margin-left: 10px; } @@ -258,31 +273,37 @@ img.o_forum_avatar_big { .o_wforum_bio_popover_info .css_editable_mode_hidden > div:last-child > .o_forum_tooltip_line { margin-top: -0.5rem; // compensate parent 'mt-2' class } - - .o_wforum_bio_popover_bio p { - margin-top: 8px; - margin-bottom: 0; - } -} - -.o_wforum_elearning_navtabs_container { - @include media-breakpoint-up(md) { - background-color: map-get($theme-colors, 'secondary'); - } -} - -.website_forum { - margin-bottom: $spacer; } .popover.o_wforum_bio_popover_container { + box-shadow: 0 2px 5px $gray-400; + + .popover-header { + background-color: tint-color($primary, 80%); + border-bottom: 0; + color: $primary; + } + max-width: 552px; -} -.o_wforum_forum_card_bg { - background-image: linear-gradient(99deg, map-get($theme-colors, 'secondary') 10%, darken(map-get($theme-colors, 'secondary'), 10%) 90%); + .o-text-gold { + color: $gold; + } - #o_wforum_forums_index_list & { - min-height: 100px; + .o-text-silver { + color: $silver; + } + + .o-text-bronze { + color: $bronze; + } +} + +#o_wforum_forums_index_list .card { + border-radius: .75rem; + + &:hover { + transform: translateY(-5px); + box-shadow: $box-shadow !important; } } diff --git a/addons/website_forum/static/src/xml/public_templates.xml b/addons/website_forum/static/src/xml/public_templates.xml index 95458e9a303..4eb4b62508b 100644 --- a/addons/website_forum/static/src/xml/public_templates.xml +++ b/addons/website_forum/static/src/xml/public_templates.xml @@ -3,7 +3,7 @@