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
+
+ %(hide_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 @@