[IMP] website_forum: redesign frontend, improve customer experience

This PR supports the redesign of website_forum with additional features supporting
searches, tags, moderation, and links.

It includes slightly modified version of the commits in PR #120982 and additional
feature and refactoring tasks.

See details in each commit.

Task-3083657
Task-3316555

closes odoo/odoo#123709

Related: odoo/enterprise#42388
Related: odoo/upgrade#4764
Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
Co-authored-by: Elisabeth Dickinson <edi@odoo.com>
Co-authored-by: Florian Charlier <flch@odoo.com>
Co-authored-by: Lopes Marc (loma) <loma@odoo.com>
This commit is contained in:
Odoo's Mergebot
2023-07-18 16:58:46 +02:00
committed by GitHub
co-authored by Elisabeth Dickinson Florian Charlier Lopes Marc
37 changed files with 2042 additions and 1757 deletions
+1 -1
View File
@@ -2538,7 +2538,7 @@ Sitemap: <t t-esc="url_root"/>sitemap.xml
<div class="d-flex align-items-center o_search_result_item">
<img t-if="result.get('image_url')" t-att-src="result.get('image_url')" class="flex-shrink-0 o_image_64_contain"/>
<i t-else="" t-att-class="'o_image_64_contain text-center pt16 fa %s' % result.get('_fa')" style="font-size: 34px;"/>
<div class="o_search_result_item_detail px-3">
<div class="o_search_result_item_detail px-3 text-break">
<t t-set="description" t-value="result.get('description')"/>
<t t-set="extra_link" t-value="result.get('extra_link_url') and result.get('extra_link')"/>
<t t-set="extra_link_html" t-value="not result.get('extra_link_url') and result.get('extra_link')"/>
+3 -1
View File
@@ -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',
+115 -74
View File
@@ -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/<model("forum.forum"):forum>/tag', '/forum/<model("forum.forum"):forum>/tag/<string:tag_char>'], 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/<model("forum.forum"):forum>/tag',
'/forum/<model("forum.forum"):forum>/tag/<string:tag_char>',
], 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/<model("forum.forum"):forum>/question/<model("forum.post"):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/<model("forum.forum"):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/<model("forum.forum"):forum>/post/<model("forum.post"):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/<model("forum.forum"):forum>/post/<model("forum.post"):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/<model("forum.forum"):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/<model("forum.forum"):forum>/post/<model("forum.post"):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/<model("forum.forum"):forum>/post/<model("forum.post"):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/<model("forum.forum"):forum>/user/<int:user_id>'], 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 = {
@@ -3,6 +3,7 @@
<record id="forum_help" model="forum.forum">
<field name="name">Help</field>
<field name="image_1920" type="base64" file="website_forum/static/src/img/help.jpg"/>
<field name="description">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.</field>
</record>
@@ -5,15 +5,19 @@
<field name="type">qweb</field>
<field name="key">website_forum.faq_accordion</field>
<field name="arch" type="xml">
<section class="s_faq_collapse pt32 pb32">
<t t-set="_page_name" t-value="'guidelines'"/>
<t t-set="_page_name_label">Guidelines</t>
<section class="s_faq_collapse mb-5">
<div class="container">
<div id="myCollapse" class="accordion" role="tablist">
<div class="card bg-white" data-name="Item">
<a href="#" role="tab" data-bs-toggle="collapse" aria-expanded="false" class="collapsed card-header" data-bs-target="#collapse1">
<b>What kinds of questions can I ask here?</b>
</a>
<div id="collapse1" class="collapse" data-bs-parent="#myCollapse" role="tabpanel">
<div class="card-body">
<div id="myCollapse" class="accordion rounded border" role="tablist">
<div class="accordion-item" data-name="Item">
<h2 class="accordion-header" id="headingOne">
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#collapse1">
What kind of questions can I ask here?
</button>
</h2>
<div id="collapse1" class="accordion-collapse collapse show" data-bs-parent="#myCollapse" aria-labelledby="headingOne">
<div class="accordion-body bg-light">
<p>This community is for professional and enthusiast users, partners and programmers. You can ask questions about:</p>
<ul>
<li>how to install Odoo on a specific infrastructure,</li>
@@ -27,12 +31,14 @@
</div>
</div>
</div>
<div class="card bg-white" data-name="Item">
<a href="#" role="tab" data-bs-toggle="collapse" aria-expanded="false" class="collapsed card-header" data-bs-target="#collapse2">
<b>What should I avoid in my questions?</b>
</a>
<div id="collapse2" class="collapse" data-bs-parent="#myCollapse" role="tabpanel">
<div class="card-body">
<div class="accordion-item" data-name="Item">
<h2 class="accordion-header" id="headingTwo">
<button class="collapsed accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#collapse2">
What should I avoid in my questions?
</button>
</h2>
<div id="collapse2" class="collapse" data-bs-parent="#myCollapse" aria-labelledby="headingTwo">
<div class="accordion-body bg-light">
<p>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.</p>
<p>To prevent your question from being flagged and possibly removed, avoid asking subjective questions where …</p>
<ul>
@@ -54,28 +60,32 @@
</div>
</div>
</div>
<div class="card bg-white" data-name="Item">
<a href="#" role="tab" data-bs-toggle="collapse" aria-expanded="false" class="collapsed card-header" data-bs-target="#collapse3">
<b>What should I avoid in my answers?</b>
</a>
<div id="collapse3" class="collapse" data-bs-parent="#myCollapse" role="tabpanel">
<div class="card-body">
<div class="accordion-item" data-name="Item">
<h2 class="accordion-header" id="headingThree">
<button class="collapsed accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#collapse3">
What should I avoid in my answers?
</button>
</h2>
<div id="collapse3" class="collapse" data-bs-parent="#myCollapse" aria-labelledby="headingThree">
<div class="accordion-body bg-light">
<p><b>Answers should not add or expand questions</b>. Instead, either edit the question or add a comment.</p>
<p><b>Answers should not comment other answers</b>. Instead add a comment on the other answers.</p>
<p><b>Answers shouldn't just point to other questions</b>.Instead add a comment indicating <i>"Possible duplicate of..."</i>. However, it's fine to include links to other questions or answers providing relevant additional information.</p>
<p> <b>Answers shouldn't just provide a link a solution</b>. 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.</p>
<p><b>Answers shouldn't just provide a link a solution</b>. 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.</p>
<p><b>Answers should not start debates</b> This community Q&amp;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.</p>
<p>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.</p>
<p>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.</p>
</div>
</div>
</div>
<div class="card bg-white" data-name="Item">
<a href="#" role="tab" data-bs-toggle="collapse" aria-expanded="false" class="collapsed card-header" data-bs-target="#collapse4">
<b>Why can other people edit my questions/answers?</b>
</a>
<div id="collapse4" class="collapse" data-bs-parent="#myCollapse" role="tabpanel">
<div class="card-body">
<div class="accordion-item" data-name="Item">
<h2 class="accordion-header" id="headingFour">
<button class="collapsed accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#collapse4">
Why can other people edit my questions/answers?
</button>
</h2>
<div id="collapse4" class="collapse" data-bs-parent="#myCollapse" aria-labelledby="headingFour">
<div class="accordion-body bg-light">
<p>The goal of this site is create a relevant knowledge base that would answer questions related to Odoo.</p>
<p>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.</p>
<p>If this approach is not for you, please respect the community.</p>
@@ -89,4 +99,4 @@
</field>
</record>
</data>
</odoo>
</odoo>
+79 -31
View File
@@ -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 """
<section>
<div class="container py-5">
<div class="row">
<div class="col-lg-12">
<h1 class="text-center">Welcome!</h1>
<p class="text-400 text-center">%(message_intro)s<br/>%(message_post)s</p>
</div>
<div class="col text-center mt-3">
<a href="#" class="js_close_intro btn btn-outline-light mr-2">%(hide_text)s</a>
<a class="btn btn-light forum_register_url" href="/web/login">%(register_text)s</a>
</div>
</div>
</div>
</section>""" % {
'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("""
<h1 style="text-align: center;clear-both"><font style="font-size: 62px; font-weight: bold;">%(message_intro)s</font></h1>
<div class="text-white">
<p class="lead o_default_snippet_text" style="text-align: center;">%(message_post)s</p>
<p style="text-align: center;">
<a class="btn btn-primary forum_register_url" href="/web/login">%(register_text)s</a>
<button type="button" class="btn btn-light js_close_intro" aria-label="Dismiss message">
%(hide_text)s
</button>
</p>
</div>
""") % {
'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
+34 -37
View File
@@ -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
# ----------------------------------------------------------------------
@@ -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))
+40 -3
View File
@@ -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(','))),
}
+1 -1
View File
@@ -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')
+2
View File
@@ -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):
@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="b" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 203.0829 243.7154"><g id="c"><g id="d"><polygon points="101.5414 238.2685 20.0382 190.7423 20.0382 101.8237 101.5414 148.9972 101.5414 238.2685" style="fill:#c1dbf6;"/><polygon points="101.5414 238.2685 183.044 191.095 183.044 101.8237 101.5414 148.9972 101.5414 238.2685" style="fill:#fff;"/><polygon points="183.044 101.8237 183.044 158.6371 111.3156 202.3055 101.5414 148.9972 183.044 101.8237" style="fill:#c1dbf6;"/><polygon points="20.8288 101.9859 101.5414 148.9972 101.5414 148.6905 101.5414 54.625 20.8288 101.9859" style="fill:#c1dbf6;"/><polygon points="101.5414 54.625 101.5414 148.9972 183.044 101.8237 101.5414 54.625" style="fill:#374874;"/><path d="m155.6613,118.1373l-23.2882-13.2862c-2.5378-1.4928-4.1799-4.1799-4.1799-7.1656h0c0-3.2842,3.5828-5.3742,6.4192-3.7321l24.6317,14.1819c2.09,1.1943,3.4335,3.5828,3.4335,5.9713h0c.1493,3.5828-3.8814,5.822-7.0163,4.0306Z" style="fill:#fff;"/><path d="m101.5414,54.625l81.5026,47.1988,14.3455,43.1314-14.3455,8.2813v37.8586l-81.5025,47.1734-81.5032-47.5262v-32.0983l-.0114-.007v-5.4006l-14.3455-8.2813,14.3455-43.1314,81.5146-47.1987m0-5.4541L17.6841,97.7379l-1.5591.894-.5672,1.7053L1.2123,143.4687l-1.2123,3.6449,3.3267,1.9204,11.9903,6.9217v5.3231l.0114.007v32.1621l2.3372,1.3629,83.8758,48.9045,83.8617-48.5441,2.3506-1.3604v-37.8551l11.9903-6.9217,3.3388-1.9273-1.2296-3.6538-14.4874-43.0493-.5615-1.6686-1.5184-.8911-83.7445-48.673h0Z" style="fill:#374874;"/><polygon points="183.044 101.8237 101.5414 54.625 20.2749 101.9673 20.0269 101.8237 5.6814 144.9551 87.3993 192.1287 101.5414 148.9972 24.9857 104.6936 101.5414 60.2454 178.3204 104.5575 101.5414 148.9972 115.6715 192.1287 197.3895 144.9551 183.044 101.8237" style="fill:#fff;"/><path d="m49.8818,64.403c-.6002,0-1.2004-.2277-1.6592-.6842l-24.5309-24.3699c-.9233-.9164-.9279-2.4078-.0115-3.33.9176-.9222,2.4101-.9268,3.33-.0115l24.5309,24.3699c.9233.9164.9279,2.4078.0115,3.33-.4599.4634-1.0659.6957-1.6707.6957Z" style="fill:#374874;"/><path d="m150.4164,64.403c-.6048,0-1.2108-.2323-1.6707-.6957-.9164-.9222-.9118-2.4135.0115-3.33l24.5309-24.3699c.9199-.9176,2.4124-.913,3.33.0115.9164.9222.9118,2.4135-.0115,3.33l-24.5309,24.3699c-.4588.4565-1.059.6842-1.6592.6842Z" style="fill:#374874;"/><path d="m101.5353,41.5659c-1.3005,0-2.3549-1.0544-2.3549-2.3549V2.3549c0-1.3005,1.0544-2.3549,2.3549-2.3549s2.3549,1.0544,2.3549,2.3549v36.8561c0,1.3005-1.0544,2.3549-2.3549,2.3549Z" style="fill:#374874;"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.4 KiB

@@ -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."),
}, {
@@ -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`<p>${sprintf(_t('Oh no! Please <a href="%s">sign in</a> to vote'), "/web/login")}</p>`
);
return;
}
const forumId = parseInt(document.getElementById('wrapwrap').dataset.forum_id);
const additionalInfoWithForumID = forumId
? Markup`<br/>
<a class="alert-link" href="/forum/${forumId}/faq">
${_t("Read the guidelines to know how to gain karma.")}
</a>`
: "";
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}<br/>
<a class="alert-link" href="/forum/${encodeURIComponent(forumID)}/faq">${linkLabel}</a>
`;
}
}
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`<p>${sprintf(_t('Oh no! Please <a href="%s">sign in</a> to vote'), "/web/login")}</p>`
: 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({
@@ -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);
},
@@ -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;
}
}
@@ -3,7 +3,7 @@
<div role="dialog" class="modal fade" id="oe_social_share_modal">
<div class="modal-dialog" role="document">
<div class="modal-content">
<header class="modal-header alert alert-info mb0" role="status">
<header class="modal-header mb0" role="status">
<h4 class="modal-title">Thanks for posting!</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</header>
@@ -57,10 +57,10 @@
<t t-name="website.social_alert">
<div class="alert alert-info alert-dismissable" role="status">
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<p>Move this question to the top of the list by sharing it on social networks.</p><br/>
<p>Share this post on social networks.</p><br/>
<div>
<t t-foreach="medias" t-as="media">
<a style="cursor: pointer" t-attf-class="fa-stack fa-lg share oe_share_bump #{media}" t-attf-aria-label="Share on #{media}" t-attf-title="Share on #{media}">
<a style="cursor: pointer" t-attf-class="fa-stack fa-lg share #{media}" t-attf-aria-label="Share on #{media}" t-attf-title="Share on #{media}">
<span class="fa fa-square fa-stack-2x"></span>
<span t-attf-class="oe_social_#{media} fa fa-#{media} fa-stack-1x fa-inverse"></span>
</a>
@@ -1,13 +1,14 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
registry.category("web_tour.tours").add('forum_question', {
test: true,
url: '/forum/help-1',
steps: [{
import { registry } from "@web/core/registry";
registry.category("web_tour.tours").add('forum_question', {
test: true,
url: '/forum/help-1',
steps: [
{
content: "Ask the question in this forum by clicking on the button.",
trigger: '.o_forum_ask_btn',
trigger: '.o_wforum_ask_btn',
}, {
content: "Give your question content.",
trigger: 'input[name=post_name]',
@@ -52,11 +53,9 @@
content: "Close modal once modal animation is done.",
extra_trigger: 'div.modal.modal_shown',
trigger: ".modal-header button.btn-close",
}, {
content: "Click here to accept this answer.",
extra_trigger: '#wrap:has(".o_wforum_validate_toggler")',
trigger: '.o_wforum_validate_toggler[data-karma]:first',
}, {
content: "Congratulations! You just created and post your first question and answer.",
trigger: '#wrap:has(".o_wforum_answer_correct")',
}]});
trigger: '.o_wforum_validate_toggler',
isCheck: true,
}]
});
+12
View File
@@ -2,6 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests import common
from odoo.tools import mute_logger
KARMA = {
'ask': 5, 'ans': 10,
@@ -104,3 +105,14 @@ class TestForumCommon(common.TransactionCase):
cls.website_2 = cls.env['website'].create({
'name': 'Second Website on same company',
})
@mute_logger("odoo.models.unlink")
def _activate_tags_for_counts(self):
self.env['forum.tag'].search([]).unlink()
self.tags = self.env['forum.tag'].create(
[
{'forum_id': forum_id.id, 'name': f'Test Tag {tag_idx}'}
for forum_id in self.forum | self.base_forum
for tag_idx in range(1, 8)
]
)
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.website_forum.models.forum_forum import MOST_USED_TAGS_COUNT
from odoo.addons.website_forum.tests.common import KARMA, TestForumCommon
from odoo.tests import tagged, users
@@ -107,3 +108,40 @@ class TestTags(TestForumCommon):
self.assertEqual(len(food_tags), 2, "One Food tag should have been created in each forum.")
self.assertIn(forum_1, food_tags.forum_id, "One Food tag should have been created for forum 1.")
self.assertIn(forum_2, food_tags.forum_id, "One Food tag should have been created for forum 2.")
def test_tags_usage_most_used(self):
self._activate_tags_for_counts()
posts_per_tag = [(tag_id, post_count) for tag_id, post_count in zip(self.base_forum.tag_ids, range(2, 9))]
vals_list = [
{'forum_id': self.base_forum.id, 'name': 'A post', 'content': 'A content', 'tag_ids': [tag_id.id]}
for tag_id, post_count in posts_per_tag
for __ in range(post_count)
]
self.env['forum.post'].create(vals_list)
self.env['forum.tag'].flush_model()
self.assertListEqual(
self.base_forum.tag_most_used_ids.ids,
[tag_id.id for tag_id, _ in reversed(posts_per_tag[-MOST_USED_TAGS_COUNT:])],
)
self.assertEqual(self.base_forum.tag_unused_ids, self.env['forum.tag'])
def test_tags_usage_unused(self):
self._activate_tags_for_counts()
used_tag = self.forum.tag_ids[5]
self.env['forum.post'].create(
[
{'forum_id': tag_id.forum_id.id, 'tag_ids': [tag_id.id], 'name': 'A post', 'content': 'A content'}
for tag_id in (used_tag | self.base_forum.tag_ids)
]
)
self.env['forum.tag'].flush_model()
# trigger batch compute
__ = (self.forum | self.base_forum).tag_most_used_ids
self.assertEqual(self.forum.tag_most_used_ids, used_tag)
self.assertEqual(self.forum.tag_unused_ids, self.forum.tag_ids - used_tag)
self.assertEqual(self.base_forum.tag_most_used_ids, self.base_forum.tag_ids[:MOST_USED_TAGS_COUNT])
self.assertEqual(self.base_forum.tag_unused_ids, self.env['forum.tag'])
+10 -18
View File
@@ -1,35 +1,27 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import Command
from odoo.addons.website_forum.tests.common import TestForumCommon
class TestForumTag(TestForumCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env['forum.tag'].search([]).unlink()
cls.tags = cls.env['forum.tag'].create(
[{'forum_id': cls.forum.id, 'name': f'Test Tag {tag_idx}'} for tag_idx in range(1, 3)]
)
cls.env['forum.post'].create(
[{'name': 'Posting about tag 1', 'forum_id': cls.forum.id, 'tag_ids': [[6, 0, [cls.tags[0].id]]]}]
)
def _check_tags_post_counts(self, tags, expected_counts):
self.assertEqual(tags.mapped('posts_count'), expected_counts)
def test_tag_posts_count(self):
self._check_tags_post_counts(self.tags, [1, 0])
self._activate_tags_for_counts()
test_tags = self.tags[:2]
self._check_tags_post_counts(test_tags, [0, 0])
post_tag_1 = self.env['forum.post'].create(
[{'name': 'Posting about tag 1 again', 'forum_id': self.forum.id, 'tag_ids': [[6, 0, [self.tags[0].id]]]}]
[{'name': 'Posting about tag 1', 'forum_id': self.forum.id, 'tag_ids': [Command.set([test_tags[0].id])]}]
)
self._check_tags_post_counts(self.tags, [2, 0])
self._check_tags_post_counts(test_tags, [1, 0])
post_tags = self.env['forum.post'].create(
[{'name': 'Posting about tag 2 now', 'forum_id': self.forum.id, 'tag_ids': [[6, 0, self.tags.ids]]}]
[{'name': 'Posting with both tags now', 'forum_id': self.forum.id, 'tag_ids': [Command.set(test_tags.ids)]}]
)
self._check_tags_post_counts(self.tags, [3, 1])
self._check_tags_post_counts(test_tags, [2, 1])
post_tag_1.active = False
self._check_tags_post_counts(self.tags, [2, 1])
self._check_tags_post_counts(test_tags, [1, 1])
post_tags.close(None)
self._check_tags_post_counts(self.tags, [1, 0])
self._check_tags_post_counts(test_tags, [0, 0])
@@ -15,11 +15,11 @@
<div t-if="options.get('badges')" style="display: inline-block">
<t t-esc="separator"/>
<b>|</b>
<span class="fa fa-trophy bg-gold ms-2" role="img" aria-label="Gold badge" title="Gold badge"/>
<span class="fa fa-trophy o-text-gold ms-2" role="img" aria-label="Gold badge" title="Gold badge"/>
<t t-esc="object.gold_badge"/>
<span class="fa fa-trophy bg-silver ms-2" role="img" aria-label="Silver badge" title="Silver badge"/>
<span class="fa fa-trophy o-text-silver ms-2" role="img" aria-label="Silver badge" title="Silver badge"/>
<t t-esc="object.silver_badge"/>
<span class="fa fa-trophy bg-bronze ms-2" role="img" aria-label="Bronze badge" title="Bronze badge"/>
<span class="fa fa-trophy o-text-bronze ms-2" role="img" aria-label="Bronze badge" title="Bronze badge"/>
<t t-esc="object.bronze_badge"/>
</div>
<t t-out="0"/>
@@ -4,183 +4,49 @@
<!-- Specific Forum Layout -->
<template id="forum_index" name="Forum">
<t t-set="no_filters" t-value="filters not in ('solved', 'unsolved', 'unanswered')"/>
<t t-if="my == 'mine'">
<t t-set="_page_name" t-value="'my_posts'"/>
<t t-set="_page_name_label">My Posts</t>
</t>
<t t-elif="my == 'favourites'">
<t t-set="_page_name" t-value="'my_favourites'"/>
<t t-set="_page_name_label">My Favorites</t>
</t>
<t t-else="" t-set="_page_name" t-value="'list_questions'"/>
<t t-call="website_forum.header">
<div class="row g-0">
<div t-attf-class="d-flex justify-content-end flex-md-grow-1 #{(search or tag or my) and 'col-12 flex-column-reverse flex-md-row mb-3' or 'col-md-auto order-md-3'}">
<div t-if="search or tag or my" class="d-flex flex-wrap align-items-center flex-grow-1">
<span t-if="search" class="w-100 w-md-auto mb-2 mb-md-0 border rounded ps-2 d-inline-flex align-items-center justify-content-between">
<em class="bg-light px-2" t-esc="search"/>
<a t-att-href="url_for('') + '?' + keep_query( 'filters', 'sorting', 'my')" class="btn py-1">&#215;</a>
</span>
<span t-if="my" t-attf-class="w-100 w-md-auto mb-2 mb-md-0 border rounded ps-2 d-inline-flex align-items-center justify-content-between #{search and 'ms-md-2'}">
<div>
<img t-if="uid" class="o_forum_avatar rounded-circle me-1" t-att-src="website.image_url(user, 'avatar_128', '16x16')" alt="Avatar"/>
<span t-if="my == 'favourites'"> My <b>Favourites</b></span>
<span t-elif="my == 'followed'"> I'm <b>Following</b></span>
<span t-elif="my == 'mine'"> My <b>Posts</b></span>
<span t-elif="my == 'tagged'"> <b>Tags</b> I Follow</span>
</div>
<a t-att-href="url_for('') + '?' + keep_query( 'search', 'filters', 'sorting')" class="btn py-1">&#215;</a>
</span>
<span t-if="tag" t-attf-class="w-100 w-md-auto mb-2 mb-md-0 border rounded ps-2 d-inline-flex align-items-center justify-content-between #{(search or my) and 'ms-md-2'}">
<div>
<span class="fa fa-tag text-muted me-1"/>
<span t-esc="tag.name"/>
</div>
<a t-att-href="url_for('/forum') + '/' + slug(forum) + '?' + keep_query( 'search', 'sorting', 'filters', 'my')" class="btn py-1">&#215;</a>
</span>
</div>
<div t-if="uid and forum and forum.has_pending_post"
title="You already have a pending post"
data-bs-toggle="popover" data-bs-trigger="hover" data-bs-content="Please wait for a moderator to validate your previous post before continuing.">
<a class="disabled btn btn-secondary btn-block mb-3 mb-md-0" t-attf-href="/forum/#{slug(forum)}/ask">New Post</a>
</div>
<a t-else="" role="button" type="button" class="btn btn-primary btn-block o_forum_ask_btn mb-3 mb-md-0" t-att-href="uid and '/forum/' + slug(forum) + '/ask' or '/web/login'">New Post</a>
</div>
<t t-set="no_filters" t-value="not filters in ('solved', 'unsolved', 'unanswered')"/>
<t t-if="not no_filters or (no_filters and question_count)">
<!-- Filter post by type (mobile only) -->
<div class="col-6 col-md-auto d-lg-none d-flex align-items-center">
<div class="dropdown"> Show
<a href="#" class="dropdown-toggle" data-bs-toggle="dropdown">
<t t-if="no_filters"> All</t>
<t t-elif="filters == 'solved'"> Solved</t>
<t t-elif="filters == 'unsolved'"> Unsolved</t>
<t t-elif="filters == 'unanswered'"> Unanswered</t>
<!-- List questions or search/filters result -->
<table t-if="question_count != 0" class="o_wforum_table table mb-5" height="1">
<thead>
<tr class="d-none d-lg-table-row">
<th class="o_wforum_table_title"></th>
<th class="o_wforum_table_posters"></th>
<th class="o_wforum_table_replies small fw-normal text-center">
<a class="text-muted" t-attf-href="?#{ keep_query('search', 'filters', sorting='child_count asc' if sorting == 'child_count desc' else 'child_count desc') }">
Replies <i t-attf-class="fa fa-caret-#{ 'down' if sorting == 'child_count desc' else 'up' } #{ not sorting.startswith('child_count') and 'd-none ' }"></i>
</a>
<div class="dropdown-menu" role="menu">
<a t-att-href="url_for('') + '?' + keep_query( 'search', 'sorting', filters='all')"
class="dropdown-item">
All
</a>
<div class="dropdown-divider"/>
<t t-if="forum.mode == 'questions'">
<a t-att-href="url_for('') + '?' + keep_query( 'search', 'sorting', filters='solved')"
class="dropdown-item">Solved
</a>
<a t-att-href="url_for('') + '?' + keep_query( 'search', 'sorting', filters='unsolved')"
class="dropdown-item">Unsolved
</a>
</t>
<a t-att-href="url_for('') + '?' + keep_query( 'search', 'sorting', filters='unanswered')"
class="dropdown-item">Unanswered
</a>
</div>
</div>
</div>
<!-- Filter post by type (desktop) -->
<div class="d-none d-lg-flex align-items-center col-auto flex-grow-md-1 flex-grow-lg-0">
<nav class="o_wforum_nav nav nav-pills justify-content-around">
<a t-att-href="url_for('') + '?' + keep_query('search', 'sorting', 'my', filters='all')"
t-attf-class="nav-link py-1 rounded-pill #{no_filters and 'active fw-bold' or 'ps-0 pe-2'}">
All
</th>
<th class="o_wforum_table_views small fw-normal text-center">
<a class="text-muted" t-attf-href="?#{ keep_query('search', 'filters', sorting='views asc' if sorting == 'views desc' else 'views desc') }">
Views <i t-attf-class="fa fa-caret-#{'down' if sorting == 'views desc' else 'up'} #{ not sorting.startswith('views') and 'd-none ' }"></i>
</a>
<t t-if="forum.mode == 'questions'">
<span class="mx-1 text-400 d-none d-lg-block">|</span>
<a t-att-href="url_for('') + '?' + keep_query( 'search', 'sorting', 'my', filters='solved')"
t-attf-class="nav-link py-1 rounded-pill #{filters == 'solved' and 'active fw-bold' or 'px-2'}">Solved
</a>
<a t-att-href="url_for('') + '?' + keep_query( 'search', 'sorting', 'my', filters='unsolved')"
t-attf-class="d-none d-lg-block nav-link py-1 rounded-pill #{filters == 'unsolved' and 'active fw-bold' or 'px-2'}">Unsolved
</a>
</t>
<span class="mx-1 text-400 d-none d-lg-block">|</span>
<a t-att-href="url_for('') + '?' + keep_query( 'search', 'sorting', 'my', filters='unanswered')"
t-attf-class="nav-link py-1 rounded-pill #{filters == 'unanswered' and 'active fw-bold' or 'px-2'}">Unanswered
</th>
<th class="o_wforum_table_activity small fw-normal text-center">
<a class="text-muted" t-attf-href="?#{ keep_query('search', 'filters', sorting='last_activity asc' if sorting == 'last_activity_date desc' else 'last_activity_date desc') }">
Activity <i t-attf-class="fa fa-caret-#{ 'down' if sorting == 'last_activity_date desc' else 'up'} #{ not sorting.startswith('last_activity') and 'd-none ' }"></i>
</a>
</nav>
</div>
</th>
</tr>
</thead>
<t t-foreach="question_ids" t-as="question" t-call="website_forum.display_post">
<t t-set="show_author_avatar" t-value="true"/>
</t>
<!-- Order by -->
<div t-if="question_count > 1"
t-attf-class="col-6 col-md-auto d-flex align-items-center justify-content-end #{uid and 'mt-lg-0'}">
<span class="mx-3 mx-lg-2 text-400 d-none d-md-inline">|</span>
<span class="dropdown">
Order by
<a href="#" class="dropdown-toggle" data-bs-toggle="dropdown">
<t t-if="sorting == 'relevancy desc'"> trending</t>
<t t-elif="sorting == 'create_date desc'"> newest</t>
<t t-elif="sorting == 'write_date desc'"> activity date</t>
<t t-elif="sorting == 'child_count desc'"> most answered</t>
<t t-elif="sorting == 'vote_count desc'"> most voted</t>
</a>
<div class="dropdown-menu dropdown-menu-end" role="menu">
<a role="menuitem" t-att-href="url_for('') + '?' + keep_query( 'search', 'filters', sorting='relevancy desc')" t-attf-class="dropdown-item#{sorting == 'relevancy desc' and ' active'}">Trending</a>
<a role="menuitem" t-att-href="url_for('') + '?' + keep_query( 'search', 'filters', sorting='write_date desc')" t-attf-class="dropdown-item#{sorting == 'write_date desc' and ' active'}">Last activity date</a>
<a role="menuitem" t-att-href="url_for('') + '?' + keep_query( 'search', 'filters', sorting='create_date desc')" t-attf-class="dropdown-item#{sorting == 'create_date desc' and ' active'}">Newest</a>
<a role="menuitem" t-att-href="url_for('') + '?' + keep_query( 'search', 'filters', sorting='child_count desc')" t-attf-class="dropdown-item#{sorting == 'child_count desc' and ' active'}">Most answered</a>
<a role="menuitem" t-att-href="url_for('') + '?' + keep_query( 'search', 'filters', sorting='vote_count desc')" t-attf-class="dropdown-item#{sorting == 'vote_count desc' and ' active'}">Most voted</a>
</div>
</span>
</div>
</div>
<div class="row mt-4">
<!-- List questions or search/filters result -->
<div t-if="question_count != 0" class="col">
<t t-foreach="question_ids" t-as="question">
<t t-call="website_forum.display_post">
<t t-set="show_author_avatar" t-value="true"/>
</t>
</t>
</div>
<!-- No posts or search/filters result -->
<div t-if="question_count == 0 or original_search" class="col">
<div t-if="search or tag or (not no_filters)" class="alert alert-info">
<t t-set="_filters_str">
<t t-if="filters == 'unanswered'">unanswered</t>
<t t-elif="filters == 'solved'">solved</t>
<t t-elif="filters == 'unsolved'">unsolved</t>
</t>
<t t-set="_my_str">
<t t-if="my == 'favourites'">in your favourites</t>
<t t-if="my == 'followed'">in your followed list</t>
<t t-if="my == 'mine'">in your posts</t>
</t>
<t t-set="_search_str"><t t-if="search">matching "<em class="fw-bold" t-esc="original_search or search"/>"</t></t>
<t t-set="_search_and_tag_str"><t t-if="search and tag">&amp;nbsp;and&amp;nbsp;</t></t>
<t t-set="_tag_str"><t t-if="tag">using the <span class="badge text-bg-light" t-esc="tag.name"/> tag</t></t>
<t t-set="result_msg">
Sorry, we could not find any <b>%s</b> result <b>%s</b> %s%s%s.
</t>
<span t-out="result_msg % (_filters_str.strip(), _my_str.strip(), _search_str.strip(), _search_and_tag_str.strip(), _tag_str.strip())"/>
<span t-if="original_search">Showing results for <em class="fw-bold" t-esc="search"/> instead.</span>
</div>
<t t-elif="not tag and not search and no_filters">
<div t-if="my == 'followed'" class="alert border">
You're not following any topic in this forum (yet).<br/>
<a t-attf-href="/forum/#{slug(forum)}/">Browse All</a>
</div>
<div t-elif="my == 'favourites'" class="alert border">
No favourite questions in this forum (yet).<br/>
<a t-attf-href="/forum/#{slug(forum)}/">Browse All</a>
</div>
<div t-elif="my == 'mine'" class="alert border">You have no posts in this forum (yet).</div>
</t>
<div t-elif="filters == 'unanswered'" class="alert alert-info">Amazing! There are no unanswered questions left!</div>
<div t-elif="no_filters and not search and not tag and not my" class="alert alert-info">
<b>This forum is empty.</b><br/>
Be the first one asking a question
</div>
<t t-if="search and question_count == 0">
<h4>Search Tips</h4>
<ul>
<li>Check your spelling and try again</li>
<li>Try searching for one or two words</li>
<li>Be less specific in your wording for a wider search result</li>
</ul>
</t>
</div>
</div>
</table>
<t t-if="question_count == 0 or original_search" t-call="website_forum.no_results_message">
<t t-set="record_name_plural">posts</t>
<t t-set="go_back_url" t-valuef="/forum/#{ slug(forum) }/"/>
</t>
<t t-call="website.pager"/>
</t>
</template>
@@ -191,49 +57,57 @@
<div t-if="forum and forum.has_pending_post" class="alert border" role="alert">
<b>You already have a pending post.</b><br/>
<p>Please wait for a moderator to validate your previous post before continuing.</p>
<a t-attf-href="/forum/#{ slug(forum) }" title="All Topics"><i class="oi oi-chevron-left me-2"/>Back to All Topics</a>
<a t-attf-href="/forum/#{ slug(forum) }" title="All Topics"><i class="oi oi-chevron-left me-2"/>Back to All Posts</a>
</div>
<form t-else="" t-attf-action="/forum/#{slug(forum)}/new" method="post" role="form" class="tag_text js_website_submit_form js_wforum_submit_form o_wforum_readable">
<div class="mb-3">
<label class="form-label" for="content">Title</label>
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="text" name="post_name" required="required" pattern=".*\S.*" t-attf-value="#{post_name}"
class="form-control form-control-lg" placeholder="A clear, explicit and concise title" title="Title must not be empty"/>
<input type="hidden" name="karma" t-attf-value="#{user.karma}" id="karma"/>
<div class="form-text small text-muted">
<a data-bs-toggle="collapse" href="#newQuestionExample" role="button" aria-expanded="false" aria-controls="newQuestionExample">
Example
<i class="fa fa-question-circle"/>
</a>
<div class="collapse" id="newQuestionExample">
<div class="text-success mt-2">
<i class="fa fa-check"/> How to configure TPS and TVQ's canadian taxes?
</div>
<div class="text-danger">
<i class="fa fa-times"/> Good morning to all! Please, can someone help solve my tax computation problem in Canada? Thanks!
<form t-else="" t-attf-action="/forum/#{slug(forum)}/new" method="post" role="form" class="tag_text js_website_submit_form js_wforum_submit_form o_wforum_readable mt-lg-3">
<div class="row mb-3">
<label class="form-label col-lg-2" for="content">Title</label>
<div class="col">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="text" name="post_name" required="required" pattern=".*\S.*" t-att-value="post_name"
class="form-control" placeholder="Write a clear, explicit and concise title" title="Title must not be empty"/>
<input type="hidden" name="karma" t-att-value="user.karma" id="karma"/>
<div class="form-text small text-muted">
<a data-bs-toggle="collapse" href="#newQuestionExample" role="button" aria-expanded="false" aria-controls="newQuestionExample">
Example
<i class="fa fa-question-circle"/>
</a>
<div class="collapse" id="newQuestionExample">
<div class="mt-2 text-success">
<i class="fa fa-check"/> How to configure TPS and TVQ's canadian taxes?
</div>
<div class="text-danger">
<i class="fa fa-times"/> Good morning to all! Please, can someone help solve my tax computation problem in Canada? Thanks!
</div>
</div>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label" for="content">Description</label>
<div class="small form-text text-muted d-inline">, consider <b>adding an example</b>. </div>
<textarea name="content" required="required" id="content" class="form-control o_wysiwyg_loader" t-att-data-karma="forum.karma_editor">
<t t-esc="question_content"/>
</textarea>
<div class="row mb-3">
<label class="form-label col-lg-2" for="content">Description</label>
<div class="col">
<textarea name="content" required="required" id="content" class="form-control o_wysiwyg_loader" t-att-data-karma="forum.karma_editor">
<t t-out="question_content"/>
</textarea>
<span class="form-text d-inline small text-muted"><i class="fa fa-lightbulb-o"></i> Tip: consider adding an example. </span>
</div>
</div>
<div class="mb-3">
<label class="form-label" for="post_tags">Tags</label>
<input type="hidden" name="karma_tag_create" t-attf-value="#{forum.karma_tag_create}" id="karma_tag_create"/>
<input type="hidden" name="karma_edit_retag" t-attf-value="#{forum.karma_edit_retag}" id="karma_edit_retag"/>
<input type="hidden" name="post_tags" placeholder="Tags" class="form-control js_select2"/>
<div class="row mb-2">
<label class="form-label col-lg-2" for="post_tags">Tags</label>
<div class="col">
<input type="hidden" name="karma_tag_create" t-att-value="forum.karma_tag_create" id="karma_tag_create"/>
<input type="hidden" name="karma_edit_retag" t-att-value="forum.karma_edit_retag" id="karma_edit_retag"/>
<input type="hidden" name="post_tags" placeholder="Tags" class="form-control js_select2"/>
</div>
</div>
<div class="mb-5">
<button type="submit" t-attf-class="btn btn-primary o_wforum_submit_post #{forum.allow_share and 'oe_social_share_call'} #{(user.karma &lt; forum.karma_ask) and 'karma_required'}"
t-att-data-karma="forum.karma_ask"
data-hashtags="#question" data-social-target-type="question">Post Your Question</button>
<a class="btn btn-secondary" title="Back to Question" t-attf-href="/forum/#{ slug(forum) }"> Discard</a>
<div class="row mb-5">
<div class="col offset-lg-2">
<button type="submit" t-attf-class="o_wforum_submit_post #{forum.allow_share and 'oe_social_share_call'} btn btn-primary #{user.karma &lt; forum.karma_ask and 'karma_required'}"
t-att-data-karma="forum.karma_ask"
data-hashtags="#question" data-social-target-type="question">Post Your Question</button>
<a class="btn btn-link" title="Back to Question" t-attf-href="/forum/#{ slug(forum) }">Discard</a>
</div>
</div>
</form>
</t>
@@ -242,113 +116,137 @@
<!-- Edition: edit a post -->
<template id="edit_post" name="Edit Post">
<t t-call="website_forum.header">
<div t-if="is_answer" class="fw-bold mb-1">Question by <t t-esc="post.parent_id.create_uid.sudo().name"/></div>
<article t-if="is_answer" class="alert border pb-0 o_wforum_readable">
<h5 class="mb-1 text-muted" t-esc="post.parent_id.name"/>
<div t-field="post.parent_id.content" class="o_wforum_post_content text-muted oe_no_empty"/>
</article>
<div t-if="is_answer" class="row g-0">
<div class="col-lg-2 pt-3">
<label class="fw-bold">Question</label>
</div>
<article class="o_wforum_readable alert col bg-light">
<i class="text-muted fw-bold" t-out="post.parent_id.name"/>
<i t-field="post.parent_id.content" class="o_wforum_post_content oe_no_empty pb-3 text-muted text-break"/>
<small class="text-end text-muted">by <t t-out="post.parent_id.create_uid.sudo().name" /></small>
</article>
</div>
<form t-attf-action="/forum/#{slug(forum)}/post/#{slug(post)}/save" method="post" role="form" class="tag_text js_website_submit_form js_wforum_submit_form o_wforum_readable">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<div t-if="not is_answer" class="mb-3">
<label class="form-label" for="post_name">Title</label>
<input type="text" name="post_name" required="required" pattern=".*\S.*" t-attf-value="#{post.name}"
class="form-control form-control-lg" placeholder="Edit your Post" title="Title must not be empty"/>
<div class="form-text small text-muted">
<a data-bs-toggle="collapse" href="#newQuestionExample" role="button" aria-expanded="false" aria-controls="newQuestionExample">
Example
<i class="fa fa-question-circle"/>
</a>
<div class="collapse" id="newQuestionExample">
<div class="my-2">Use a clear, explicit and concise title</div>
<div class="text-success">
<i class="fa fa-check"/> How to configure TPS and TVQ's canadian taxes?
</div>
<div class="text-danger">
<i class="fa fa-times"/> Good morning to all! Please, can someone help solve my tax computation problem in Canada? Thanks!
<div t-if="not is_answer" class="row mb-3">
<label class="form-label col-lg-2" for="post_name">Title</label>
<div class="col">
<input type="text" name="post_name" required="required" pattern=".*\S.*" t-att-value="post.name"
class="form-control" placeholder="Edit your Post" title="Title must not be empty"/>
<div class="form-text small text-muted">
<a data-bs-toggle="collapse" href="#newQuestionExample" role="button" aria-expanded="false" aria-controls="newQuestionExample">
Example
<i class="fa fa-question-circle"/>
</a>
<div class="collapse" id="newQuestionExample">
<div class="my-2">Use a clear, explicit and concise title</div>
<div class="text-success">
<i class="fa fa-check"/> How to configure TPS and TVQ's canadian taxes?
</div>
<div class="text-danger">
<i class="fa fa-times"/> Good morning to all! Please, can someone help solve my tax computation problem in Canada? Thanks!
</div>
</div>
</div>
</div>
</div>
<div class="mb-3">
<label t-if="not is_answer" class="form-label" for="content">Description</label>
<label t-else="" class="form-label">Your Answer</label>
<div t-if="not is_answer" class="small form-text text-muted d-inline">, consider <b>adding an example</b>. </div>
<textarea name="content" id="content" required="required" class="form-control o_wysiwyg_loader" t-att-data-karma="forum.karma_editor">
<t t-esc="post.content"/>
</textarea>
<div class="row">
<label t-if="not is_answer" class="form-label col-lg-2" for="content">Description</label>
<label t-else="" class="form-label col-lg-2">Your Answer</label>
<div class="col">
<textarea name="content" id="content" required="required" class="form-control o_wysiwyg_loader" t-att-data-karma="forum.karma_editor" t-out="post.content"/>
<span t-if="not is_answer" class="form-text d-inline small text-muted"><i class="fa fa-lightbulb-o"/> Tip: consider adding an example. </span>
</div>
</div>
<input type="hidden" name="karma" t-attf-value="#{user.karma}" id="karma"/>
<div t-if="not is_answer" class="mb-3">
<label class="form-label" for="post_tags">Tags</label>
<input type="hidden" name="karma_tag_create" t-attf-value="#{forum.karma_tag_create}" id="karma_tag_create"/>
<input type="hidden" name="karma_edit_retag" t-attf-value="#{forum.karma_edit_retag}" id="karma_edit_retag"/>
<t t-set="edit_tags_karma_fail" t-value="user.karma &lt; forum.karma_edit_retag"/>
<t t-set="edit_tags_karma_error_message">You need to have sufficient karma to edit tags</t>
<input type="text" name="post_tags" class="form-control js_select2" placeholder="Tags" t-attf-data-init-value="#{tags}" value="Tags"
t-att-readonly="edit_tags_karma_fail and 'readonly'"
t-att-title="edit_tags_karma_fail and edit_tags_karma_error_message"/>
<input type="hidden" name="karma" t-att-value="user.karma" id="karma"/>
<div t-if="not is_answer" class="row mb-2">
<label class="form-label col-lg-2" for="post_tags">Tags</label>
<div class="col">
<input type="hidden" name="karma_tag_create" t-att-value="forum.karma_tag_create" id="karma_tag_create"/>
<input type="hidden" name="karma_edit_retag" t-att-value="forum.karma_edit_retag" id="karma_edit_retag"/>
<t t-set="edit_tags_karma_fail" t-value="user.karma &lt; forum.karma_edit_retag"/>
<t t-set="edit_tags_karma_error_message">You need to have sufficient karma to edit tags</t>
<input type="text" name="post_tags" class="form-control js_select2" placeholder="Tags" t-attf-data-init-value="#{tags}" value="Tags"
t-att-readonly="edit_tags_karma_fail and 'readonly'"
t-att-title="edit_tags_karma_fail and edit_tags_karma_error_message"/>
</div>
</div>
<div class="mb-4">
<button type="submit" class="btn btn-primary o_wforum_submit_post">Save Changes</button>
<a class="btn btn-secondary" title="Back to Question" t-attf-href="/forum/#{ slug(forum) }/#{ slug(post)}">
Discard
</a>
<div class="row mb-5">
<div class="col offset-lg-2">
<button type="submit" class="o_wforum_submit_post btn btn-primary">Save Changes</button>
<a class="btn btn-link" title="Back to Question" t-attf-href="/forum/#{ slug(forum) }/#{ slug(post)}">
Discard
</a>
</div>
</div>
</form>
</t>
</template>
<template id="tag" name="Forum Tags">
<template id="forum_index_tags" name="Forum Tags">
<t t-set="_page_name" t-valuef="tags"/>
<t t-set="_page_name_label">Tags</t>
<t t-call="website_forum.header">
<nav t-if="pager_tag_chars" class="navbar navbar-light bg-light justify-content-start">
<t t-if="len(pager_tag_chars) &gt; 1">
<t t-if="len(pager_tag_chars) &lt; 11">
<span class="navbar-text me-3">Show Tags Starting By</span>
<ul class="pagination mt0 mb0">
<ul class="pagination overflow-auto mt0 mb0">
<t t-foreach="pager_tag_chars" t-as="tuple_char">
<t t-if="tuple_char_index &lt; 11">
<li t-attf-class="page-item #{active_char_tag == tuple_char[1] and 'active'}"><a t-attf-href="/forum/#{slug(forum)}/tag/#{quote_plus(tuple_char[1])}" class="page-link"><t t-esc="tuple_char[0]"/></a></li>
</t>
<li t-if="tuple_char_index &lt; 11" t-attf-class="page-item #{ active_char_tag == tuple_char[1] and 'active' }">
<a t-attf-href="/forum/#{ slug(forum) }/tag/#{ quote_plus(tuple_char[1] )}?#{ keep_query('filters', 'search') }"
class="page-link">
<t t-out="tuple_char[0]"/>
</a>
</li>
</t>
</ul>
</t>
<div t-else="" class="d-flex align-items-center" role="toolbar" aria-label="Toolbar with button groups">
<label for="filter" class="me-2">Show Tags Starting By</label>
<label for="filter" class="me-2">Show Tags Starting with</label>
<select name="filter" class="form-select w-auto" onchange="location = this.value;">
<t t-foreach="pager_tag_chars" t-as="tuple_char">
<option t-if="active_char_tag == tuple_char[1]" selected="selected" value="" t-esc="tuple_char[0]"/>
<option t-else="" t-attf-value="/forum/#{slug(forum)}/tag/#{quote_plus(tuple_char[1])}" t-esc="tuple_char[0]"/>
<option t-if="active_char_tag == tuple_char[1]" selected="selected" value="" t-out="tuple_char[0]"/>
<option t-else="" t-attf-value="/forum/#{slug(forum)}/tag/#{quote_plus(tuple_char[1]) + '?' + keep_query('filters')}"
t-out="tuple_char[0]"
/>
</t>
</select>
</div>
</nav>
</t>
<div class="row mb-5" t-if="tags">
<div class="col-md-3 mt16 o_js_forum_tag_follow" t-foreach="tags" t-as="tag">
<span t-attf-class="badge border px-2 #{tag.message_is_follower and 'border-success text-success' or 'text-bg-light text-600'}">
<i class="fa fa-tag small"/>
<t t-esc="tag.name"/>
<b class="small align-top">(<t t-esc="tag.posts_count"/>)</b>
</span>
<div class="o_forum_tag_follow_box text-center">
<div class="card shadow mt-2">
<a t-attf-href="/forum/#{ slug(forum) }/tag/#{ slug(tag) }/questions?{{ keep_query( filters='tag') }}"
class="btn btn-light">
See <t t-esc="tag.posts_count"/> post<t t-if="tag.posts_count > 1">s</t>
</a>
<em class="d-block mb-2">or</em>
<div class="input-group">
<t t-call="website_mail.follow">
<t t-set="email" t-value="user_id.email"/>
<t t-set="object" t-value="tag"/>
</t>
</div>
</div>
<div class="row g-0 mt-3 mb-5" t-if="tags">
<div class="col-md-3 o_js_forum_tag_follow" t-foreach="tags" t-as="tag">
<div t-attf-class="#{ 'd-block' if request.env.user._is_public() else 'd-flex'}">
<a t-attf-class="#{len(tag.post_ids) == 0 and 'disabled'} btn btn-link pe-0 text-decoration-none text-nowrap #{tag.message_is_follower and 'text-dark fw-bold'}"
t-attf-href="/forum/#{ slug(forum) }/tag/#{ slug(tag) }/questions?#{ keep_query(filters='tag') }}">
<t t-out="tag.name"/>
<span class="small">(<t t-out="tag.posts_count"/>)</span>
</a>
<t t-call="website_forum.follow">
<t t-set="email" t-value="user_id.email"/>
<t t-set="object" t-value="tag"/>
<t t-set="icons_design" t-value="True"/>
<t t-set="div_class" t-value="'d-inline'"/>
</t>
</div>
</div>
</div>
<div t-else="" class="alert border text-center">
No tags
</div>
<t t-else="">
<t t-set="tag_filter" t-value="keep_query('filters').split('=')[1] if keep_query('filters') else ''"/>
<t t-set="go_back_url" t-valuef="/forum/#{ slug(forum) }/tag"/>
<t t-set="record_name_plural">tags</t>
<t t-call="website_forum.no_results_message">
<t t-if="tag_filter and tag_filter != 'all'" t-set="result_msg">
There are no tags matching the selected filter <t t-if="search">and search</t>.
</t>
<t t-elif="search" t-set="result_msg">
There are no tags matching the selected search.
</t>
<t t-else="" t-set="result_msg">
There are no tags being used in this forum.
</t>
</t>
</t>
</t>
</template>
@@ -5,25 +5,21 @@
<!-- All Forums Layout -->
<template id="forum_all" name="Forum Navigation">
<t t-set="col_class" t-valuef="mb-3 col-sm-6"/>
<t t-set="col_class" t-valuef="col-md col-xl-3 col-xxl-2 mb-3"/>
<t t-set="img_class" t-valuef="col-md-4"/>
<t t-set="content_class" t-valuef="col-md-8"/>
<t t-set="last_post_class" t-valuef="col-md-12 pe-md-3 pt-3"/>
<t t-set="nb_post_class" t-valuef="col-md-12 pe-md-3 pt-2"/>
<t t-call="website.layout">
<t t-set="pageName" t-value="'website_forum'"/>
<div id="wrap">
<div class="oe_structure oe_empty" id="oe_structure_forum_all_top"/>
<div id="o_wforum_forums_index_list" class="container pt-4 pb-5">
<div t-if="forums" class="row">
<t t-call="website_forum.forum_all_all_entries">
<div id="o_wforum_forums_index_list" class="container pt-lg-4 pb-5">
<div class="row justify-content-center">
<t t-if="forums" t-call="website_forum.forum_all_all_entries">
<t t-set="_forums" t-value="forums"/>
</t>
<div t-else="" class="alert alert-info">No forum is available yet.</div>
</div>
<t t-else="">
<div class="alert alert-info">No forum is available yet.</div>
</t>
</div>
<div class="oe_structure oe_empty" id="oe_structure_forum_all_bottom"/>
</div>
@@ -39,45 +35,33 @@
<t t-foreach="sorted_forums" t-as="forum">
<t t-set="has_desc" t-value="forum.description"/>
<div t-attf-class="#{col_class}">
<div class="row py-4 bg-200 mx-1 h-100 o_forum_row">
<div t-attf-class="o_forum_image_container pe-md-0 h-100 #{img_class}">
<a t-attf-href="/forum/#{slug(forum)}">
<div t-attf-class="h-100 w-100 #{not forum.image_1920 and 'rounded o_wforum_forum_card_bg shadow-sm flex-shrink-0'}">
<div t-if="forum.image_1920 or editable" t-attf-class="h-100"
t-field="forum.image_1920" t-options="{'widget': 'image', 'preview_image': 'image_256', 'class': 'w-100 h-100 o_object_fit_cover rounded'}" />
</div>
</a>
<a t-attf-href="/forum/#{slug(forum)}"
class="card oe_img_bg h-100 justify-content-end shadow-sm border-0 p-3 pt-5 text-reset text-decoration-none transition-base"
t-attf-style="background-image: url('#{forum.image_1920 and website.image_url(forum, 'image_1920') or ''}')"
>
<div class="o_we_bg_filter" style="background-image: linear-gradient(0deg, rgba(255, 255, 255, .75) 30%, rgba(255, 255, 255, 0) 100%);"></div>
<div t-if="forum_badge" class="badge position-absolute top-0 end-0 m-3 border text-bg-light" t-out="forum_badge"></div>
<div class="mt-5 pt-5">
<h3 class="mb-0 fw-bold" t-field="forum.name"/>
<t t-if="is_view_active('website_forum.opt_post_count')">
<span t-if="forum.total_posts > 0" class="badge text-bg-dark">
<span t-out="forum.total_posts" class="me-1"/>
<t t-if="forum.total_posts > 1">posts</t>
<t t-else="">post</t>
</span>
<span t-else="" class="badge text-dark">No posts yet</span>
</t>
</div>
<div t-attf-class="#{content_class} mt-2 mt-md-0 d-flex flex-column h-100">
<a t-attf-href="/forum/#{slug(forum)}" class="text-reset" t-att-title="forum.name">
<h3 class="h4" t-field="forum.name"/>
</a>
<p class="m-0 flex-grow-1"
placeholder="Description"
t-field="forum.teaser"/>
<t t-if="is_view_active('website_forum.opt_post_count') or is_view_active('website_forum.opt_last_post')" t-call="website_forum.forum_post_options"/>
</div>
</div>
</a>
</div>
</t>
</template>
<template id="forum_post_options">
<div class="row">
<div t-attf-class="#{last_post_class}">
<div t-if="is_view_active('website_forum.opt_last_post') and forum.post_ids" class="text-truncate">Last Post: <a t-attf-href="/forum/#{slug(forum)}/#{slug(forum.last_post_id)}"><t t-esc="forum.last_post_id.name"/></a></div>
</div>
<div t-attf-class="#{nb_post_class}">
<div t-if="is_view_active('website_forum.opt_post_count')">Posts: <strong><t t-esc="forum.total_posts"/></strong></div>
</div>
</div>
</template>
<!-- (Options) Forum : List View
Display forums as a list -->
<template name="List View" id="website_forum.opt_list_view" inherit_id="website_forum.forum_all" active="False">
<xpath expr="//t[@t-set='col_class']" position="attributes">
<attribute name="t-valuef">mb-3 col-sm-12</attribute>
<attribute name="t-valuef">col-8 mb-3</attribute>
</xpath>
<xpath expr="//t[@t-set='img_class']" position="attributes">
<attribute name="t-valuef">col-md-3</attribute>
@@ -85,12 +69,6 @@
<xpath expr="//t[@t-set='content_class']" position="attributes">
<attribute name="t-valuef">col-md-9</attribute>
</xpath>
<xpath expr="//t[@t-set='last_post_class']" position="attributes">
<attribute name="t-valuef">col-md-10 pe-md-0 pt-3</attribute>
</xpath>
<xpath expr="//t[@t-set='nb_post_class']" position="attributes">
<attribute name="t-valuef">col-md-2 pe-md-3 pt-3</attribute>
</xpath>
</template>
<!-- (Options) Forum : Show Post Count
@@ -106,18 +84,13 @@
<template id="forum_all_oe_structure_forum_all_top" inherit_id="website_forum.forum_all" name="Forum Navigation (oe_structure_forum_all_top)">
<xpath expr="//*[hasclass('oe_structure')][@id='oe_structure_forum_all_top']" position="replace">
<div class="oe_structure oe_empty" id="oe_structure_forum_all_top">
<section class="s_cover parallax s_parallax_is_fixed bg-black-50 py-5" data-scroll-background-ratio="1" data-snippet="s_cover">
<span class="s_parallax_bg oe_img_bg" style="background-image: url('/web/image/website.s_cover_default_image'); background-position: 50% 0;"/>
<div class="o_we_bg_filter bg-black-50"/>
<div class="container">
<div class="row s_nb_column_fixed">
<div class="col-lg-12">
<h1 class="o_default_snippet_text text-center">Our forums</h1>
<p class="lead o_default_snippet_text mb-0" style="text-align: center;">
This community is for professional and enthusiast users, partners and programmers.
</p>
</div>
</div>
<section class="s_cover o_colored_level s_parallax_no_overflow_hidden pt96 pb48" data-scroll-background-ratio="0" data-snippet="s_cover" data-name="Cover">
<div class="s_allow_columns container">
<h1 style="text-align: center;"><font style="font-size: 62px; font-weight: bold;">Community Forums</font></h1>
<p class="lead" style="text-align: center;">Tap into the collective knowledge of our community by asking your questions in our forums,<br/> where helpful members are ready to assist you.</p>
<p style="text-align: center;">
<a class="mb-2" href="/profile/users?forum_origin=#{request.httprequest.path}" data-bs-original-title="" title="" target="_blank">Meet our community members</a>
</p>
</div>
</section>
</div>
@@ -17,82 +17,201 @@
<!-- PAGE INDEX -->
<!-- ============================================================ -->
<template id="forum_model_nav">
<!-- allows to be overridden such as in website_slides_forum -->
<t t-set="breadcrumb_kind" t-valuef="base"/>
<nav id="o_wforum_nav" t-attf-class="navbar d-flex align-items-center gap-2 #{_page_name == 'single_question' and 'mw-xl-75 mw-xxl-100'} px-0" aria-label="breadcrumb">
<t t-if="_page_name == 'single_question'">
<div class="flex-grow-1">
<div class="o_wforum_breadcrumb_root_single row g-0">
<div t-if="breadcrumb_kind == 'base'" class="col-10">
<a class="btn btn-lg btn-link px-0 pb-2" t-attf-href="/forum/#{ slug(forum) }">
<i class="d-inline-block oi oi-chevron-left me-1 small lh-50"/><t t-out="forum.name"/>
</a>
</div>
<div class="d-lg-none col-2 text-end">
<button class="btn position-relative ms-auto" data-bs-toggle="offcanvas" data-bs-target="#o_wforum_offcanvas">
<i class="fa fa-navicon"/>
</button>
</div>
</div>
<div class="d-flex gap-2 align-items-baseline">
<h3 t-attf-class="col-lg-10 mb-0" t-out="question.name"/>
<div class="col d-flex justify-content-end align-items-center">
<i t-if="question.state == 'close'" class="fa fa-lock ms-2 fs-4" title="Closed" data-bs-toggle="tooltip" data-bs-placement="top"/>
<span t-elif="not question.active" class="badge bg-danger">
<t t-if="question.state!='offensive'">Deleted</t>
<t t-if="question.state=='offensive'">Offensive</t>
<t t-if="question.state=='offensive' and question.closed_reason_id">
<t t-out="question.closed_reason_id.name.capitalize()"/>
</t>
</span>
<a t-elif="uid" type="button" aria-label="Favorite"
t-attf-data-href="/forum/#{slug(question.forum_id)}/question/#{slug(question)}/toggle_favourite"
t-attf-class="o_wforum_favourite_toggle btn btn-lg #{ not question.user_favourite and 'opacity-50' } opacity-hover-100 px-1">
<i t-attf-class="position-relative fa #{question.user_favourite and 'o_wforum_gold fa-star ' or 'fa-star-o '}"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="Favorite"/>
</a>
<t t-if="question.state == 'active'" t-call="website_forum.follow">
<t t-set="object" t-value="question"/>
<t t-set="icons_design" t-value="True"/>
<t t-set="btn_classes" t-value="'btn-lg opacity-50 opacity-100-hover' "/>
</t>
</div>
</div>
</div>
</t>
<t t-elif="_page_name == 'list_questions' or is_edit">
<t t-set="target" t-value="post.parent_id if is_answer else post"/>
<div class="o_wforum_breadcrumb_root_list_or_edit d-flex">
<div t-if="breadcrumb_kind=='base'" class="col-10 col-lg flex-grow-1">
<h5 t-if="not is_edit" class="fw-bold mb-0" t-out="forum.name"/>
<h5 t-elif="not is_answer" class="fw-bold mb-0">Edit Question</h5>
<h5 t-else="is_answer" class="fw-bold mb-0">Edit Answer</h5>
</div>
</div>
</t>
<ol t-else="" class="breadcrumb col-10 col-lg flex-grow-1 mb-0 p-0">
<li class="o_wforum_breadcrumb_root breadcrumb-item text-nowrap">
<h5 class="mb-0"><a t-attf-href="/forum/#{ slug(forum) }" t-out="forum.name"/></h5>
</li>
<li t-if="queue_type" class="breadcrumb-item text-nowrap d-none d-lg-flex">
<h5 class="mb-0">Moderation</h5>
</li>
<li class="breadcrumb-item text-nowrap">
<h5 class="fw-bold mb-0" t-out="_page_name_label"/>
</li>
</ol>
<t t-if="_page_name != 'single_question'">
<div class="d-lg-none text-end">
<button class="btn position-relative ms-auto" data-bs-toggle="offcanvas" data-bs-target="#o_wforum_offcanvas">
<i class="fa fa-navicon"/>
</button>
</div>
<div class="d-flex justify-content-lg-end gap-2 flex-grow-1 mw-lg-50">
<span t-if="tag and not tags" class="btn btn-light rounded ps-2">
<span t-if="tag"><i class="fa fa-tag me-1 text-muted"/><t t-out="tag.name"></t></span>
<a t-attf-href="#{ url_for('/forum') }/#{ slug(forum) }?#{ keep_query('search', 'sorting', 'my') }"
class="p-1 text-decoration-none text-muted"><i class="oi oi-close d-inline-block"/></a>
</span>
<span t-if="search" class="btn btn-light rounded ps-2">
<em t-if="search" class="text-muted">"<t t-out="search"/>"</em>
<a t-attf-href="?#{ keep_query('sorting', 'my') }" class="p-1 text-decoration-none text-muted">
<i class="oi oi-close d-inline-block"/>
</a>
</span>
<div t-if="question_count != 0 or tags" class="dropdown ms-lg-auto">
<t t-if="_page_name == 'tags'" t-set="tag_filter" t-value="keep_query('filters') and keep_query('filters').split('=')[1] or ''"/>
<a href="#" class="btn btn-light dropdown-toggle" data-bs-toggle="dropdown">
<!-- Foreach tag_filter/ Foreach filters -->
<t t-if="filters == 'all' or tag_filter == 'all' or not tag_filter and not filters">All</t>
<t t-if="_page_name == 'tags'">
<t t-if="tag_filter == 'followed'">Followed Tags</t>
<t t-elif="tag_filter == 'unused'">Unused Tags</t>
<t t-elif="tag_filter == 'most_used'">Most used Tags</t>
</t>
<t t-else="">
<t t-if="filters == 'solved'">Solved</t>
<t t-elif="filters == 'unsolved'">Unsolved</t>
<t t-elif="filters == 'unanswered'">Unanswered</t>
</t>
</a>
<div class="dropdown-menu" role="menu">
<a t-attf-href="?#{ keep_query('search', 'sorting', filters='all') }"
class="dropdown-item">
All
</a>
<div class="dropdown-divider"/>
<t t-if="_page_name == 'tags'">
<a t-if="not request.env.user._is_public()"
t-attf-href="?#{ keep_query('search', 'sorting', 'my', filters='followed') }"
class="dropdown-item">Followed Tags
</a>
<t t-if="forum.can_moderate">
<a t-attf-href="?#{ keep_query('search', 'sorting', 'my', filters='unused') }"
class="dropdown-item">Unused Tags
</a>
</t>
<a t-attf-href="?#{ keep_query('search', 'sorting', 'my', filters='most_used') }"
class="dropdown-item">Most Used Tags
</a>
</t>
<t t-else="">
<t t-if="forum.mode == 'questions'">
<a t-attf-href="?#{ keep_query('search', 'sorting', 'my', filters='solved') }"
class="dropdown-item">Solved
</a>
<a t-attf-href="?#{ keep_query('search', 'sorting', 'my', filters='unsolved') }"
class="dropdown-item">Unsolved
</a>
</t>
<a t-attf-href="?#{ keep_query('search', 'sorting', 'my', filters='unanswered') }"
class="dropdown-item">Unanswered
</a>
</t>
</div>
</div>
<!-- 'Search Box' -->
<t t-if="question_count or tags" t-call="website.website_search_box_input">
<t t-if="_page_name == 'tags'">
<t t-set="search_type" t-value="'forum_tags_only'"/>
<t t-set="action" t-valuef="/forum/#{ slug(forum) }/tag"/>
</t>
<t t-else="">
<t t-set="search_type" t-value="'forums'"/>
<t t-set="action" t-value="'/forum/' + slug(forum)"/>
</t>
<t t-set="display_description" t-valuef="true"/>
<t t-set="display_detail" t-valuef="true"/>
<t t-set="_form_classes" t-valuef="flex-grow-1"/>
<t t-set="_input_classes" t-valuef="border-0 bg-light"/>
<t t-set="_submit_classes" t-valuef="btn-light"/>
<input t-if="filters" type="hidden" name="filters" t-att-value="filters"/>
<input t-if="my" type="hidden" name="my" t-att-value="my"/>
<input t-if="sorting" type="hidden" name="sorting" t-att-value="sorting"/>
</t>
<t t-if="_page_name == 'list_questions'">
<t t-set="popover_title">You already have a pending post</t>
<t t-set="popover_content">Please wait for a moderator to validate your previous post before continuing.</t>
<div t-if="uid and forum and forum.has_pending_post"
data-bs-toggle="popover"
t-att-data-bs-title="popover_title"
t-att-data-bs-content="popover_content">
<a class="o_wforum_ask_btn disabled btn btn-primary mb-3 mb-md-0" t-attf-href="/forum/#{ slug(forum) }/ask">New Post</a>
</div>
<a t-elif="uid" role="button" type="button" t-attf-class="o_wforum_ask_btn btn btn-primary #{ user.karma &lt; forum.karma_ask and 'karma_required' }"
t-att-data-karma="forum.karma_ask" t-attf-href="/forum/#{slug(forum)}/ask">New Post</a>
</t>
<button t-if="website_forum_action and not queue_type == 'close' and question_count != 0" type="button" class="btn btn-secondary"
data-bs-toggle="modal" data-bs-target="#markAllAsSpam">
<i class="fa fa-bug me-1"/>Filter Tool
</button>
</div>
</t>
</nav>
</template>
<template id="header" name="Forum Index">
<t t-if="forum.active" t-call="website_forum.layout">
<section t-attf-class="s_cover parallax s_parallax_is_fixed py-3 #{forum.image_1920 and 'bg-black-50' or 'o_wforum_forum_card_bg text-white'}" data-scroll-background-ratio="1" data-snippet="s_cover">
<span t-if="forum.image_1920" class="s_parallax_bg oe_img_bg" t-attf-style="background-image: url('#{website.image_url(forum, 'image_1920')}'); background-position: center;"/>
<div t-if="forum.image_1920" class="o_we_bg_filter bg-black-50"/>
<div class="container">
<div class="row s_nb_column_fixed">
<div class="col-lg-12">
<h1 class="o_default_snippet_text text-center"><t t-esc="forum.name"></t></h1>
</div>
</div>
<div t-if="editable or (is_public_user and not forum_welcome_message)" t-att-class="'css_non_editable_mode_hidden' if editable else 'forum_intro'">
<div t-field="forum.welcome_message"/>
</div>
</div>
</section>
<div class="o_forum_nav_header_container mb-2 mb-md-4">
<t t-call="website_forum.forum_nav_header"></t>
</div>
<div id="wrap" t-attf-class="container #{website_forum_action}">
<div class="row">
<div class="col o_wprofile_email_validation_container mb16">
<div class="oe_structure" id="oe_structure_website_forum_header_1"/>
<div id="wrap" t-attf-class="o_wforum_wrapper position-relative container d-flex d-xl-grid px-lg-0 #{website_forum_action}">
<t t-set="_forum_path" t-value="url_for('/forum') + '/' + slug(forum)"/>
<t t-call="website_forum.user_sidebar"/>
<t t-call="website_forum.user_sidebar_mobile"/>
<div class="o_wforum_content_wrapper w-100 w-xl-auto px-lg-3">
<div class="o_wprofile_email_validation_container row g-0 justify-content-center mb-3 mb-lg-5 pt-2 pt-lg-3">
<t t-call="website_profile.email_validation_banner">
<t t-set="redirect_url" t-value="'/forum/%s' % forum.id"/>
<t t-set="additional_validation_email_message"> and join this Forum</t>
<t t-set="additional_validated_email_message"> You may now participate in our forums.</t>
</t>
<div class="row">
<div class="col">
<nav t-if="header.get('is_guidelines') or queue_type or new_question or is_edit or tags or reasons" aria-label="breadcrumb">
<ol class="breadcrumb p-0 bg-white">
<li class="breadcrumb-item">
<a t-attf-href="/forum/#{ slug(forum) }" t-esc="forum.name"/>
</li>
<t t-if="header.get('is_guidelines')">
<li class="breadcrumb-item">
<a t-if="header.get('is_karma')" t-attf-href="/forum/#{ slug(forum) }/faq">Guidelines</a>
<t t-else="">
Guidelines
</t>
</li>
<li t-if="header.get('is_karma')" class="breadcrumb-item">Karma</li>
</t>
<li t-if="queue_type" class="breadcrumb-item">Moderation</li>
<li t-if="queue_type == 'validation'" class="breadcrumb-item">To Validate</li>
<li t-if="queue_type == 'flagged'" class="breadcrumb-item">Flagged</li>
<li t-if="queue_type == 'offensive'" class="breadcrumb-item">Offensive</li>
<li t-if="reasons and offensive" class="breadcrumb-item">Offensive Post</li>
<li t-if="reasons and not offensive" class="breadcrumb-item">Close Post</li>
<li t-if="new_question" class="breadcrumb-item">New Post</li>
<t t-if="is_edit">
<t t-set="target" t-value="post.parent_id if is_answer else post"/>
<li class="breadcrumb-item text-truncate" style="max-width:150px">
<a t-attf-href="/forum/#{ slug(forum) }/#{ slug(target)}" title="Back to Question">
<t t-esc="target.name"/>
</a>
</li>
<li t-if="not is_answer" class="breadcrumb-item">Edit Question</li>
<li t-if="is_answer" class="breadcrumb-item">Edit Answer</li>
</t>
<li t-elif="tags" class="breadcrumb-item">All Tags</li>
</ol>
</nav>
<t t-out="0"/>
</div>
<aside t-if="uid" class="d-none d-lg-flex justify-content-end col-auto">
<t t-call="website_forum.user_sidebar"/>
</aside>
</div>
<t t-call="website_forum.forum_model_nav"/>
<t t-out="0"/>
</div>
</div>
</div>
<div class="oe_structure" id="oe_structure_website_forum_header_1"/>
</t>
<t t-else="" t-call="website_forum.layout">
<t t-set="head">
@@ -104,183 +223,171 @@
</t>
</template>
<template id="forum_nav_header">
<div class="navbar navbar-expand-sm navbar-light">
<div class="container flex-wrap flex-md-nowrap">
<a t-if="has_back_button_url" class="btn btn-light border me-2 o_back_button" title="Back">
<i class="oi oi-chevron-left me-1"/>Back
<!-- User sidebar -->
<template id="user_sidebar">
<aside class="o_wforum_sidebar z-index-1 d-none d-lg-flex flex-column h-100 o_cc o_cc2">
<div class="flex-grow-1 px-2">
<t t-call="website_forum.user_sidebar_header"/>
<t t-call="website_forum.user_sidebar_body"/>
</div>
<div class="o_wforum_sidebar_footer mt-3 px-3 pb-2 text-center">
<a class="btn btn-sm btn-link" t-attf-href="/forum/#{slug(forum)}/faq">
<i class="fa fa-info-circle fa-fw"/> About this forum
</a>
<!-- Desktop -->
<ul class="navbar-nav me-auto d-none d-lg-flex">
<li class="nav-item">
<a t-if="request.website.forum_count > 1" class="nav-link" href="/forum/" title="All forums">
All Forums
</a>
</li>
<li class="nav-item">
<a t-attf-href="/forum/#{ slug(forum) }" t-attf-class="nav-link #{question_count and 'active'}">Topics</a>
</li>
<li class="nav-item">
<a t-attf-href="/profile/users?forum_origin=#{request.httprequest.path}"
t-attf-class="nav-link #{searches.get('users') and 'active'}">People</a>
</li>
<li class="nav-item">
<a t-attf-href="/forum/#{ slug(forum) }/tag" t-attf-class="nav-link #{searches.get('tags') and 'active'}">Tags</a>
</li>
<li class="nav-item">
<a t-attf-href="/profile/ranks_badges?badge_category=forum&amp;url_origin=#{request.httprequest.path}&amp;name_origin=#{forum.name}"
t-attf-class="nav-link #{searches.get('badges') and 'active'}">Badges</a>
</li>
<li class="nav-item">
<a t-attf-href="/forum/#{ slug(forum) }/faq" t-attf-class="nav-link #{header.get('is_guidelines') and 'active'}">About</a>
</li>
</ul>
</div>
</aside>
</template>
<!-- Mobile -->
<ul class="navbar-nav d-lg-none flex-row flex-grow-1 justify-content-between">
<span class="navbar-text me-1">Go to:</span>
<li class="nav-item dropdown me-auto">
<a class="nav-link active dropdown-toggle" type="button" data-bs-toggle="dropdown">
<t t-if="searches.get('users')">People</t>
<t t-elif="searches.get('tags')">Tags</t>
<t t-elif="searches.get('badges')">Badges</t>
<t t-elif="header.get('is_guidelines')">About</t>
<t t-elif="uid and my == 'favourites'">Favourites</t>
<t t-elif="uid and my == 'mine'">My Posts</t>
<t t-elif="uid and my == 'followed'">Following</t>
<t t-elif="question">Question</t>
<t t-else="">All Topics</t>
</a>
<div class="dropdown-menu position-absolute">
<a t-if="searches or my or question" t-attf-href="/forum/#{ slug(forum) }" class="dropdown-item">All Topics</a>
<a t-if="not searches.get('users')" t-attf-href="/profile/users?forum_origin=#{request.httprequest.path}" class="dropdown-item">People</a>
<a t-if="not searches.get('tags')" t-attf-href="/forum/#{slug(forum)}/tag" class="dropdown-item">Tags</a>
<a t-if="not searches.get('badges')" t-attf-href="/profile/ranks_badges?badge_category=forum&amp;url_origin=#{request.httprequest.path}&amp;name_origin=#{forum.name}" class="dropdown-item">Badges</a>
<a t-if="not header.get('is_guidelines')" t-attf-href="/forum/#{ slug(forum) }/faq" class="dropdown-item">About</a>
<t t-if="uid">
<div class="dropdown-divider"/>
<a t-att-href="'/forum/%s/user/%s?forum_origin=%s' % (slug(forum), uid, request.httprequest.path)"
class="dropdown-item">My profile</a>
<a t-if="my != 'mine'" t-att-href="url_for('') + '?' + keep_query( 'search', 'sorting', 'filters', my='mine')" class="dropdown-item">My Posts</a>
<a t-if="my != 'favourites'" t-att-href="url_for('') + '?' + keep_query( 'search', 'sorting', 'filters', my='favourites')" class="dropdown-item">My Favourites</a>
<a t-if="my != 'followed'" t-att-href="url_for('') + '?' + keep_query( 'search', 'sorting', 'filters', my='followed')" class="dropdown-item">I'm Following</a>
<a t-if="my != 'tagged'" t-att-href="url_for('') + '?' + keep_query( 'search', 'sorting', 'filters', my='tagged')" class="dropdown-item">Tags I Follow</a>
</t>
<div groups="base.group_erp_manager" class="dropdown-divider"/>
<a groups="base.group_erp_manager" t-attf-href="/web#id=#{forum.id}&amp;view_type=form&amp;model=forum.forum" class="dropdown-item">Edit Forum in Backend</a>
</div>
</li>
<t t-if="user.karma>=forum.karma_moderate">
<li t-if="forum.count_posts_waiting_validation" class="nav-item">
<a class="nav-link" t-attf-href="/forum/#{slug(forum)}/validation_queue">
<i class="fa fa-check-square-o fa-fw text-warning"/>
<b t-esc="forum.count_posts_waiting_validation" class="text-800"/>
</a>
</li>
<li t-if="forum.count_flagged_posts" class="nav-item ms-2">
<a class="nav-link" t-attf-href="/forum/#{slug(forum)}/flagged_queue">
<i class="fa fa-flag fa-fw text-danger"/>
<b t-esc="forum.count_flagged_posts" class="text-800"/>
</a>
</li>
</t>
<!-- Mobile 'Search Box' toggler-->
<li class="nav-item ms-4">
<a data-bs-toggle="collapse" href="#o_wforum_search" class="nav-link"><i class="fa fa-search"/></a>
</li>
</ul>
<!-- 'Search Box' -->
<t t-call="website.website_search_box_input">
<t t-set="_classes" t-valuef="w-100 w-md-auto pt-2 pt-md-0 d-md-flex"/>
<t t-set="search_type" t-valuef="forums"/>
<t t-set="action" t-value="'/forum/%s%s' % (slug(forum), tag and ('/tag/%s/questions' % slug(tag)) or '')"/>
<t t-set="display_description" t-valuef="true"/>
<t t-set="display_detail" t-valuef="true"/>
<input t-if="filters" type="hidden" name="filters" t-att-value="filters"/>
<input t-if="my" type="hidden" name="my" t-att-value="my"/>
<input t-if="sorting" type="hidden" name="sorting" t-att-value="sorting"/>
</t>
<!-- Off canvas user sidebar on mobile -->
<template id="user_sidebar_mobile">
<div id="o_wforum_offcanvas" class="o_website_offcanvas offcanvas offcanvas-end d-lg-none mw-75 p-0 overflow-visible">
<button type="button" class="btn-close mt-3 ms-auto me-3" data-bs-dismiss="offcanvas" aria-label="Close"/>
<div class="offcanvas-header align-items-start px-0" t-call="website_forum.user_sidebar_header"/>
<div class="offcanvas-body d-flex flex-column ps-0 py-0">
<t t-call="website_forum.user_sidebar_body"/>
<div class="mb-2 d-flex justify-content-center align-items-end flex-grow-1">
<a class="btn btn-sm btn-link" t-att-href="_forum_path + '/faq'">
<i class="fa fa-info-circle fa-fw"/> About this forum
</a>
</div>
</div>
</div>
</template>
<template id="website_forum.user_sidebar">
<nav t-if="uid" class="o_wforum_nav nav nav-pills flex-column ms-4">
<a t-attf-href="/forum/#{slug(forum)}/user/#{uid}?forum_origin=#{request.httprequest.path}"
class="nav-link d-flex align-items-center rounded-pill text-reset mb-2"
data-bs-toggle="tooltip"
data-trigger="hover"
<!-- User sidebar elements -->
<template id="user_sidebar_header">
<t t-set="_location" t-value="('/tag/' + slug(tag) + '/questions?') if tag else '?' "/>
<div t-if="not uid" class="o_wforum_sidebar_section mt-4 text-center">
<div class="alert alert-info mb-2"><span>You need to be registered to interact with the community.</span>
<a t-if="is_public_user and forum_welcome_message" href="/web/login" class="btn btn-primary mt-2">Sign up</a>
</div>
</div>
<div t-if="uid" class="o_wforum_sidebar_section mt-4 mb-2">
<a t-attf-href="#{ _forum_path }/user/#{ uid }?forum_origin=#{ request.httprequest.path }"
class="btn w-100 py-1 text-start d-flex align-items-center" data-bs-toggle="tooltip" data-trigger="hover"
title="My profile">
<img class="o_forum_avatar rounded-circle me-1" t-att-src="website.image_url(user, 'avatar_128', '30x30')" alt="Avatar"/>
<div>
<h6 class="my-0" t-esc="user_id.name"/>
<small class="text-muted fw-bold"><t t-esc="user_id.karma"/>xp</small>
<img class="o_wforum_avatar rounded-circle" t-att-src="request.website.image_url(user, 'avatar_128', '60x60')" alt="Avatar"/>
<div class="d-flex flex-column justify-content-center ms-2">
<h6 class="mb-1" t-out="user_id.name"/>
<small class="text-muted"><t t-out="user_id.karma"/> XP</small>
</div>
</a>
</div>
</template>
<t t-set="location" t-value="url_for('/forum') + '/' + slug(forum) + ( ('/tag/' + slug(tag) + '/questions?') if tag else '?' )"/>
<!-- My Posts -->
<span t-if="my == 'mine'" class="nav-link rounded-pill mb-2 active fw-bold">
<i class="fa fa-question-circle-o fa-fw"/> My Posts
<a class="text-reset pull-right no-decoration" t-att-href="location + keep_query('search', 'filters', 'sorting')">&#215;</a>
</span>
<a t-else="" class="nav-link rounded-pill mb-2 text-reset" t-att-href="location + keep_query('search', 'filters', 'sorting', my='mine')">
<i class="fa fa-question-circle-o fa-fw"/> My Posts
<template id="user_sidebar_body">
<div class="o_wforum_sidebar_section">
<t t-set="location" t-valuef="#{ _forum_path }#{ ('/tag/' + slug(tag) + '/questions') if (tag and not tags) else '' }?"/>
<!-- All Posts -->
<a t-attf-class="nav-link my-1 py-1 #{ 'bg-primary-light text-primary disabled' if request.httprequest.path == _forum_path and no_filters and not any([my, queue_type, tags]) else 'text-reset'}" t-att-href="location">
<i t-attf-class="fa fa-list fa-fw #{ 'opacity-50' if request.httprequest.path != _forum_path or my or queue_type else '' }"/> Posts
</a>
<!-- My Favourites -->
<span t-if="my == 'favourites'" class="nav-link rounded-pill mb-2 active fw-bold">
<i class="fa fa-star fa-fw"/> Favourites
<a class="text-reset pull-right no-decoration" t-att-href="location + keep_query( 'search', 'filters', 'sorting')">&#215;</a>
</span>
<a t-else="" t-attf-class="nav-link rounded-pill mb-2 text-reset" t-att-href="location + keep_query( 'search', 'filters', 'sorting', my='favourites')">
<i class="fa fa-star fa-fw"/> Favourites
</a>
<!-- My Followed posts -->
<span t-if="my == 'followed'" class="nav-link rounded-pill mb-2 active fw-bold">
<i class="fa fa-bell fa-fw"/> Followed Posts
<a class="text-reset pull-right no-decoration" t-att-href="location + keep_query( 'search', 'filters', 'sorting')">&#215;</a>
</span>
<a t-else="" class="nav-link rounded-pill mb-2 text-reset" t-att-href="location + keep_query( 'search', 'filters', 'sorting', my='followed')">
<i class="fa fa-bell fa-fw"/> Followed Posts
</a>
<!-- My Followed tags -->
<span t-if="my == 'tagged'" class="nav-link rounded-pill mb-2 active fw-bold">
<i class="fa fa-tags fa-fw"/> Followed Tags
<a class="text-reset pull-right no-decoration" t-att-href="location + keep_query( 'search', 'filters', 'sorting')">&#215;</a>
</span>
<a t-else="" class="nav-link rounded-pill mb-2 text-reset" t-att-href="location + keep_query( 'search', 'filters', 'sorting', my='tagged')">
<i class="fa fa-tags fa-fw"/> Followed Tags
</a>
<!-- Moderation Tools -->
<t t-if="user.karma>=forum.karma_moderate or queue_type">
<span class="nav-link disabled mt-3">
<div class="pb-1 border-bottom text-muted">Moderation tools</div>
</span>
<span t-if="queue_type == 'validation'" class="nav-link rounded-pill mb-2 active fw-bold">
<i class="fa fa-check-square-o fa-fw"/> To Validate
<a class="text-reset pull-right no-decoration" t-attf-href="/forum/#{ slug(forum) }">&#215;</a>
</span>
<a t-else="" class="nav-link rounded-pill text-reset" t-attf-href="/forum/#{slug(forum)}/validation_queue">
<i class="fa fa-check-square-o fa-fw"/> To Validate
<span t-attf-class="badge pull-right #{forum.count_posts_waiting_validation > 0 and 'text-bg-warning' or 'text-bg-light'}" t-esc="forum.count_posts_waiting_validation"/>
<t t-if="uid">
<!-- My Posts -->
<a t-attf-class="nav-link my-1 py-1 #{my == 'mine' and 'bg-primary-light text-primary disabled' or 'text-reset'}" t-att-href="location + keep_query('search', 'filters', 'sorting', my='mine')">
<i t-attf-class="fa fa-user-circle fa-fw #{my != 'mine' and 'opacity-50'}"/> My Posts
</a>
<span t-if="queue_type == 'offensive' or queue_type == 'flagged'" class="nav-link rounded-pill mb-2 active fw-bold">
<i class="fa fa-flag fa-fw"/> Flagged
<a class="text-reset pull-right no-decoration" t-attf-href="/forum/#{ slug(forum) }">&#215;</a>
</span>
<a t-else="" class="nav-link rounded-pill text-reset" t-attf-href="/forum/#{slug(forum)}/flagged_queue">
<i class="fa fa-flag fa-fw"/> Flagged
<span id="count_flagged_posts" t-attf-class="badge pull-right #{forum.count_flagged_posts > 0 and 'text-bg-danger' or 'text-bg-light'}" t-esc="forum.count_flagged_posts"/>
<!-- My Favourites -->
<a t-attf-class="nav-link my-1 py-1 #{my == 'favourites' and 'bg-primary-light text-primary disabled' or 'text-reset'}" t-att-href="location + keep_query( 'search', 'filters', 'sorting', my='favourites')">
<i t-attf-class="fa fa-star fa-fw #{my != 'favourites' and 'opacity-50'}"/> Favourites
</a>
</t>
</nav>
<!-- People -->
<a t-attf-class="nav-link my-1 py-1 text-reset" t-attf-href="/profile/users?forum_origin=#{request.httprequest.path}">
<i class="fa fa-users fa-fw opacity-50"/> People
</a>
<!-- Badges -->
<a t-attf-class="nav-link my-1 py-1 text-reset" t-attf-href="/profile/ranks_badges?forum_origin=#{request.httprequest.path}">
<i class="fa fa-shield fa-fw opacity-50"/> Badges
</a>
</div>
<div t-if="user.karma >= forum.karma_moderate" class="o_wforum_sidebar_section pt-3">
<!-- Moderation Tools -->
<div class="px-3 pb-1 fw-bold">Moderation tools</div>
<a t-attf-class="nav-link my-1 py-1 #{queue_type == 'validation' and 'bg-primary-light text-primary disabled' or 'text-reset'}" t-attf-href="/forum/#{slug(forum)}/validation_queue">
<i t-attf-class="fa fa-check-square-o fa-fw #{queue_type != 'validation' and 'opacity-50'}"/> To Validate
<span id="count_posts_queue_validation" t-attf-class="badge #{forum.count_posts_waiting_validation > 0 and 'text-bg-warning' or 'd-none'}" t-out="forum.count_posts_waiting_validation"/>
</a>
<a t-attf-class="nav-link my-1 py-1 #{queue_type == 'offensive' or queue_type == 'flagged' and 'bg-primary-light text-primary disabled' or 'text-reset'}" t-attf-href="/forum/#{slug(forum)}/flagged_queue">
<i t-attf-class="fa fa-flag fa-fw #{queue_type != 'flagged' and 'opacity-50'}"/> Flagged
<span id="count_posts_queue_flagged" t-attf-class="badge #{forum.count_flagged_posts > 0 and 'text-bg-danger' or 'd-none'}" t-out="forum.count_flagged_posts"/>
</a>
<a t-attf-class="nav-link my-1 py-1 #{queue_type == 'close' and 'bg-primary-light text-primary disabled' or 'text-reset'}" t-attf-href="/forum/#{slug(forum)}/closed_posts">
<i t-attf-class="fa fa-window-close fa-fw #{queue_type != 'close' and 'opacity-50'}"/> Closed
</a>
</div>
<div t-if="forum.tag_most_used_ids" class="o_wforum_sidebar_section pt-3">
<div class="d-flex align-items-center px-3 pb-1 fw-bold">Tags
<a class="ms-auto px-0 fw-normal" t-att-href="_forum_path + '/tag'">
<small>View all</small>
</a>
</div>
<a t-foreach="forum.tag_most_used_ids" t-as="tag"
t-attf-href="#{ _forum_path }/tag/#{ slug(tag) }/questions?#{ keep_query( 'search', 'sorting', 'my', filters='tag') }"
t-attf-class="nav-link my-1 py-1 text-reset">
<i class="fa fa-tag fa-fw small opacity-50"/>
<t t-out="tag.name"/>
</a>
</div>
</template>
<template id="header_welcome_message" inherit_id="website_forum.header" name="Forum Welcome Message (oe_structure_forum_top)">
<xpath expr="//*[hasclass('oe_structure')][@id='oe_structure_website_forum_header_1']" position="replace">
<div class="oe_structure oe_empty" id="oe_structure_website_forum_header_1">
<section t-if="editable or (is_public_user and not forum_welcome_message)" t-attf-class="s_cover parallax s_parallax_is_fixed bg-black-50 pt48 pb48 #{'css_non_editable_mode_hidden' if editable else 'forum_intro'}" data-scroll-background-ratio="1" data-snippet="s_cover">
<span t-if="forum.image_1920" class="s_parallax_bg oe_img_bg" t-attf-style="background-image: url('#{request.website.image_url(forum, 'image_1920')}'); background-position: center;"/>
<div t-if="forum.image_1920" class="o_we_bg_filter bg-black-50"/>
<div class="container s_allow_columns">
<div class="row" data-row-count="5">
<div class="o_colored_level offset-lg-3 col-lg-6">
<div t-field="forum.welcome_message" class="container s_allow_columns"/>
</div>
</div>
</div>
</section>
</div>
</xpath>
</template>
<template id="no_results_message">
<t t-if="queue_type">
<t t-set="_no_results_title">You've Completely Caught&amp;nbsp;Up!</t>
<t t-set="result_msg">Go enjoy a cup of coffee.</t>
<t t-set="record_name_plural">posts</t>
<t t-set="go_back_url" t-valuef="/forum/#{ slug(forum) }/"/>
</t>
<t t-else="" t-set="_no_results_title">Oops!</t>
<div t-attf-class="#{ queue_type and 'o_caught_up_alert' } row g-0 justify-content-center #{ 'd-none' if hide_alert else '' }">
<div class="p-3 d-flex flex-column align-items-center">
<img t-attf-src="/website_forum/static/src/img/#{'tasks' if queue_type else 'empty'}.svg" class="img img-fluid mb-3" width="150" alt="Checked boxes"/>
<h5 t-out="_no_results_title"/>
<span t-if="result_msg" t-out="result_msg"/>
<span t-else="">
Sorry, we could not find any <b t-if="filters in ['unanswered', 'solved', 'unsolved']" t-out="filters"/> results
<b t-if="my == 'favourites'"> in your favourites</b>
<b t-elif="my == 'mine'"> in your posts</b>
<span t-if="search">matching "<em class="fw-bold text-break" t-out="original_search or search"/>"</span>
<span t-out="'and' if search and tag else ''"/>
<t t-if="tag">
using the <span class="px-2 py-1 rounded bg-300" t-out="tag.name"/> tag
</t>.
</span>
<span t-if="original_search">Showing results for <em class="fw-bold" t-out="search"/> instead.</span>
<div t-if="question_count == 0 and forum.total_posts != 0" class="mt-3 text-start">
<p><i class="fa fa-check fa-fw me-1"/>Check your spelling and try again.</p>
<p><i class="fa fa-check fa-fw me-1"/>Try searching for one or two words.</p>
<p><i class="fa fa-check fa-fw me-1"/>Be less specific in your wording for a wider search result.</p>
</div>
<span t-elif="forum.total_posts == 0">Actually, there are no posts in this forum yet.</span>
<div class="mt-3">
<a t-if="uid and forum.total_posts == 0" role="button" type="button" class="o_forum_ask_btn btn btn-primary ms-2" t-attf-href="/forum/#{slug(forum)}/ask">Start by creating a post</a>
<a t-else="" role="button" type="button" class="btn btn-primary mt-2" t-att-href="go_back_url">Go back to the list of <t t-out="record_name_plural"/></a>
</div>
</div>
</div>
</template>
<!-- ERROR MANAGEMENT -->
@@ -292,7 +399,7 @@
<h1 class="mt-4">Question not found!</h1>
<p>Sorry, this question is not available anymore.</p>
<p>
<a t-attf-href="/forum">Return to the question list.</a>
<a class="btn-link" t-attf-href="/forum"><i class="oi oi-arrow-right display-inline-block"/> Return to questions list</a>
</p>
</t>
</template>
@@ -12,62 +12,42 @@
</t>
</template>
<template id="moderation_display_post_answer">
<div class="clearfix">
<div class="question-name">
<a style="font-size: 15px;" t-attf-href="/forum/#{ slug(answer.forum_id) }/#{ answer.parent_id.id }/#answer-#{ answer.id }" t-esc="answer.parent_id.name"/>
<b>[Answer]</b>
<span t-if="not answer.active and answer.state=='offensive'"><b> [Offensive]</b></span>
<span t-if="not answer.active and answer.state=='offensive' and answer.closed_reason_id"><b> [<t t-esc="answer.closed_reason_id.name[0].upper() + answer.closed_reason_id.name[1:]"/>]</b></span>
<t t-if="answer.state == 'flagged'">
<small class="text-muted">
Flagged
</small>
</t>
<t t-if="len(answer.website_message_ids)&gt;0">
(<t t-esc="len(answer.website_message_ids)"/>
<t t-if="len(answer.website_message_ids)&gt;1"> Comments</t>
<t t-if="len(answer.website_message_ids)&lt;=1"> Comment</t>)
</t>
</div>
<div class="clearfix"><span t-field="answer.content" class="oe_no_empty"/></div>
</div>
</template>
<!-- Moderation: close a post -->
<template id="mark_as_offensive">
<div class="o_mark_offensive">
<p class="text-muted" t-if="not offensive">
<div class="alert bg-light text-muted" t-if="not offensive">
If you close this post, it will be hidden for most users. Only
users having a high karma can see closed posts to moderate
them.
</p>
<p class="text-muted" t-if="offensive">
</div>
<div class="alert bg-light text-muted" t-if="offensive">
If you mark this post as offensive, it will be hidden for most users. Only
users having a high karma can see offensive posts to moderate
them.
</p>
<form t-attf-action="/forum/#{ slug(forum) }/#{offensive and 'post' or 'question'}/#{slug(question)}/#{offensive and 'mark_as_offensive' or 'close'}" method="post" role="form" class="mt32 js_website_submit_form">
</div>
<form t-attf-action="/forum/#{ slug(forum) }/#{offensive and 'post' or 'question'}/#{slug(question)}/#{offensive and 'mark_as_offensive' or 'close'}" method="post" role="form" class="js_website_submit_form">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input name="post_id" t-att-value="question.id" type="hidden"/>
<div class="mb-3">
<label class="form-label" for="post">Post:</label>
<input type="text" disabled="True" class="form-control-plaintext" name="post" t-att-value="question.name if not question.parent_id else question.parent_id.name"/>
<div class="row g-0 mb-3">
<label class="form-label col-lg-2" for="post">Post:</label>
<input type="text" disabled="True" class="form-control-plaintext col" name="post" t-att-value="question.name if not question.parent_id else question.parent_id.name"/>
</div>
<div class="mb-4">
<label class="form-label" for="reason"><t t-if="offensive">Offensive</t><t t-if="not offensive">Closing</t> Reason:</label>
<select class="form-select form-select form-select-lg" name="reason_id">
<div class="row g-0 mb-4">
<label class="form-label col-lg-2" for="reason"><t t-if="offensive">Offensive</t><t t-if="not offensive">Closing</t> Reason:</label>
<select class="form-select form-select col" name="reason_id">
<t t-foreach="reasons or []" t-as="reason">
<option t-att-value="reason.id" t-att-selected="reason.id == question.closed_reason_id.id"><t t-esc="reason.name"/></option>
<option t-att-value="reason.id" t-att-selected="reason.id == question.closed_reason_id.id"><t t-out="reason.name"/></option>
</t>
</select>
</div>
<div class="mb-3">
<button type="submit" class="btn btn-danger">
<t t-if="offensive">Mark as offensive</t>
<t t-if="not offensive">Close post</t>
</button>
<a role="button" class="btn btn-light border ms-2" t-attf-href="/forum/#{ slug(forum) }/#{ slug(question) }">Discard</a>
<div class="row g-0 mb-3">
<div class="col offset-lg-2">
<button type="submit" class="btn btn-danger">
<t t-if="offensive">Mark as offensive</t>
<t t-if="not offensive">Close post</t>
</button>
<a role="button" class="btn btn-link ms-2" t-attf-href="/forum/#{ slug(forum) }/#{ slug(question) }">Discard</a>
</div>
</div>
</form>
</div>
@@ -80,23 +60,22 @@
</template>
<template id="moderation_queue" name="Forum Moderation Queue">
<t t-set="_page_name" t-value="queue_type"/>
<t t-set="_page_name_label" t-if="queue_type == 'validation'">To Validate</t>
<t t-set="_page_name_label" t-elif="queue_type == 'flagged'">Flagged</t>
<t t-set="_page_name_label" t-elif="queue_type == 'offensive'">Offensive Posts</t>
<t t-set="_page_name_label" t-elif="queue_type == 'close'">Closed Posts</t>
<t t-set="website_forum_action" t-valuef="o_wforum_moderation_queue"/>
<t t-call="website_forum.header">
<t t-set="website_forum_action" t-value="'o_wforum_moderation_queue'"/>
<div t-if="len(posts_ids) > 0" class="mb-2 text-end">
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#markAllAsSpam"><i class="fa fa-bug"></i> Filter Tool</button>
</div>
<div t-attf-class="o_caught_up_alert alert text-center #{len(posts_ids) and 'd-none'}">
<i class="fa fa-check text-success d-block display-2"></i>
<b>You've Completely Caught Up!</b><br/>
<t t-if="queue_type == 'validation'">No post to be validated</t>
<t t-if="queue_type == 'flagged'">No flagged posts</t>
</div>
<t t-call="website_forum.no_results_message"><t t-set="hide_alert" t-value="len(posts_ids) > 0"/></t>
<div id="queue_type" t-att-data-queue-type="queue_type"/>
<div class="modal fade" t-att-data-spam-ids="str(posts_ids.ids)" id="markAllAsSpam" tabindex="-1" role="dialog" aria-labelledby="markAllAsSpam" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header d-flex align-items-center pb-0">
<div class="text-muted me-2">Filter by:</div>
<div class="me-2 text-muted">Filter by:</div>
<ul class="nav nav-tabs border-bottom-0" id="myTab" role="tablist">
<li class="nav-item">
<a class="nav-link active spam_menu" id="user-tab" data-bs-toggle="tab" href="#spam_user" role="tab" aria-controls="user" aria-selected="true"><i class="fa fa-user"/> User</a>
@@ -118,10 +97,10 @@
<div class="card mb-2">
<div class="card-body py-2">
<div class="form-check">
<input type="checkbox" t-att-value="create_user.id" class="form-check-input" t-attf-id="user_#{create_user.id}"/>
<label class="form-check-label" t-attf-for="user_#{create_user.id}">
<img class="d-inline img o_forum_avatar" t-att-src="website.image_url(create_user, 'avatar_128', '40x40')" alt="Avatar"/>
<span t-esc="create_user.name" class="d-inline"/>
<input type="checkbox" t-att-value="create_user.id" class="form-check-input" t-attf-id="user_#{ create_user.id }"/>
<label class="form-check-label" t-attf-for="user_#{ create_user.id }">
<img class="d-inline img o_wforum_avatar" t-att-src="request.website.image_url(create_user, 'avatar_128', '40x40')" alt="Avatar"/>
<span t-out="create_user.name" class="d-inline"/>
</label>
</div>
</div>
@@ -138,7 +117,7 @@
<input type="checkbox" class="form-check-input" t-attf-id="country_#{country.id}" t-att-value="country.id"/>
<label class="form-check-label" t-attf-for="country_#{country.id}">
<span t-field="country.image_url" t-options='{"widget": "image_url", "class": "country_flag"}' class="me-2"/>
<span t-esc="country.name"/>
<span t-out="country.name"/>
</label>
</div>
</div>
@@ -160,32 +139,86 @@
</div>
</div>
<div t-foreach="posts_ids" t-as="question" class="post_to_validate card mb-3">
<div class="card-header">
<div class="row align-items-baseline">
<h6 class="col-md-2 mb-0">
<b t-if="question.parent_id">Answer:</b>
<b t-else="">Question:</b>
</h6>
<div class="col d-flex align-items-center justify-content-between">
<a t-attf-href="/forum/#{slug(question.forum_id)}/#{slug(question)}#{answer and ('/#answer-%s' % answer.id)}"
t-attf-title="Read: #{question.name}"
t-attf-class="text-reset"
t-out="question.name"/>
</div>
<div class="col-lg-3 d-lg-flex">
<span t-if="not question.active and question.state=='offensive'" class="badge bg-warning text-wrap flex-grow-1">Offensive</span>
<span t-if="not question.active and question.state=='offensive' and question.closed_reason_id" class="badge bg-warning text-wrap flex-grow-1" t-out="question.closed_reason_id.name.capitalize()"/>
<span t-if="question.closed_reason_id" class="badge bg-danger text-wrap flex-grow-1" t-out="question.closed_reason_id.name.capitalize()"/>
</div>
</div>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-2 col-sm-3 o_js_validation_queue border-end">
<div class="text-center d-flex align-items-end flex-sm-column justify-content-end align-items-sm-stretch">
<a t-attf-href="/forum/#{slug(forum)}/post/#{slug(question)}/validate" title="Validate" aria-label="Validate" data-bs-toggle="tooltip" class="btn border-success m-1 bg-white"><i class="fa fa-check text-success"/></a>
<a t-if="queue_type == 'validation'" t-attf-href="/forum/#{slug(forum)}/post/#{slug(question)}/refuse" data-bs-toggle="tooltip" title="Refuse" aria-label="Refuse" class="btn border-danger m-1 bg-white"><i class="fa fa-times text-danger"/></a>
<a t-if="queue_type == 'flagged'" t-attf-href="/forum/#{slug(forum)}/post/#{slug(question)}/ask_for_mark_as_offensive" data-bs-toggle="tooltip" aria-label="Mark as offensive" title="Mark as offensive" class="btn border-danger m-1 bg-white"><i class="fa fa-times text-danger"/></a>
<a href="#" t-if="queue_type == 'offensive'" disabled="True" aria-label="Offensive" title="Offensive" data-bs-toggle="tooltip" class="btn border-danger bg-white"><i class="fa fa-times m-1 text-danger"/></a>
</div>
</div>
<t t-if="question.parent_id">
<div t-foreach="question" t-as="answer" class="col-md-10 col-sm-8">
<t t-call="website_forum.moderation_display_post_answer"/>
<small class="text-muted">
<i t-attf-class="fa fa-user" role="img" aria-label="Question" />
<span>By </span><span t-field="question.create_uid" t-options='{"widget": "contact", "country_image": True, "fields": ["name", "country_id"]}' style="display: inline-block;"/>
<i class="ms-4 mr4 fa fa-calendar"/><span t-field="question.write_date" t-options='{"format":"short"}'/>
<span t-if="question.state == 'flagged'" class="text-black">
<i class="fa fa-flag ms-4 mr4"/>
Flagged
</span>
</small>
<div t-foreach="question" t-as="answer" class="d-flex flex-column col-md-9 col-sm-8 mb-2">
<span t-field="answer.content" class="oe_no_empty flex-grow-1 mb-2"/>
<div class="mt-auto">
<t t-call="website_forum.author_box">
<t t-set="object" t-value="question"/>
<t t-set="_image_classes" t-value="'rounded-circle'"/>
<t t-set="allow_biography" t-value="True"/>
<t t-set="show_image" t-value="True"/>
<t t-set="show_name" t-value="True"/>
<t t-set="compact" t-value="True"/>
</t>
<small class="text-muted">
<i class="ms-4 mr4 fa fa-calendar"/><span t-field="question.write_date" t-options='{"format":"short"}'/>
</small>
</div>
</div>
</t>
<div t-if="not question.parent_id" class="col">
<t t-call="website_forum.moderation_display_post_question_block"/>
<div t-if="not question.parent_id" class="d-flex flex-column col">
<span t-field="question.content" class="oe_no_empty flex-grow-1"/>
<div class="mt-auto">
<t t-call="website_forum.author_box">
<t t-set="object" t-value="question"/>
<t t-set="_image_classes" t-value="'rounded-circle'"/>
<t t-set="allow_biography" t-value="True"/>
<t t-set="show_image" t-value="True"/>
<t t-set="show_name" t-value="True"/>
<t t-set="compact" t-value="True"/>
</t>
<small class="text-muted">
<i class="fa fa-calendar ms-4 me-1"/><span t-field="question.write_date" t-options='{"format":"short"}'/>
</small>
</div>
</div>
<t t-set="post_url" t-valuef="/forum/#{ slug(forum) }/post/#{ slug(question) }"/>
<div class="col-sm-3 o_wforum_validation_queue">
<div class="text-center d-flex flex-row gap-2 flex-lg-column align-items-stretch mt-2 mt-lg-0">
<t t-if="queue_type == 'close'" t-call="website_forum.link_button">
<t t-set="url" t-valuef="/forum/#{ slug(forum) }/question/#{ slug(question) }/reopen"/>
<t t-set="label">Reopen</t>
<t t-set="icon" t-value="'fa fa-folder-open'"/>
<t t-set="karma" t-value="not question.can_close and question.karma_close or 0"/>
<t t-set="form_classes" t-value="'d-flex'"/>
<t t-set="classes" t-value="'btn-outline-primary flex-grow-1'"/>
</t>
<t t-if="queue_type == 'close'" t-call="website_forum.link_button">
<t t-set="url" t-valuef="#{ post_url }/delete'"/>
<t t-set="label">Delete</t>
<t t-set="icon" t-value="'fa fa-trash'"/>
<t t-set="karma" t-value="not question.can_unlink and question.karma_unlink or 0"/>
<t t-set="form_classes" t-value="'d-flex'"/>
<t t-set="classes" t-value="'btn-outline-danger flex-grow-1'"/>
</t>
<a t-else="" t-attf-href="#{ post_url }/validate" title="Validate" aria-label="Validate" class="btn btn-outline-success flex-grow-1"><i class="fa fa-check"/><span class="ms-2">Accept</span></a>
<a t-if="queue_type == 'validation'" t-attf-href="#{ post_url }/refuse" title="Refuse" aria-label="Refuse" class="btn btn-outline-danger flex-grow-1"><i class="fa fa-times"/><span class="ms-2">Reject</span></a>
<a t-if="queue_type == 'flagged'" t-attf-href="#{ post_url }/ask_for_mark_as_offensive" aria-label="Mark as offensive" title="Mark as offensive" class="btn btn-outline-danger flex-grow-1"><i class="fa fa-times"/><span class="ms-2">Offensive</span></a>
<a t-if="queue_type == 'offensive'" href="#" disabled="disabled" aria-label="Offensive" title="Offensive" class="btn btn-outline-danger flex-grow-1"><i class="fa fa-times"/><span class="ms-2">Offensive</span></a>
</div>
</div>
</div>
</div>
File diff suppressed because it is too large Load Diff
@@ -6,24 +6,22 @@
<!-- ============================================================ -->
<template id="show_flag_validator">
<a href="#" t-att-data-post-id="object.id" t-attf-data-action="validate" t-attf-class="#{'' if object.state == 'flagged' else 'd-none'} flag_validator badge text-bg-light my-2 me-2 o_wforum_flag_validator" title="Validate" data-bs-toggle="tooltip">
<i class="fa fa-check text-success"/>
<a href="#" t-att-data-post-id="object.id" t-attf-data-action="validate" t-attf-class="o_wforum_flag_validator flag_validator btn btn-success #{'' if object.state == 'flagged' else 'd-none'} my-2 me-2" title="Validate" data-bs-toggle="tooltip" data-bs-placement="top">
<i class="fa fa-check"/> Accept
</a>
<a href="#" t-attf-data-action="/forum/#{object.id}/ask_for_mark_as_offensive" t-attf-class="#{'' if object.state == 'flagged' else 'd-none'} flag_validator badge text-bg-light my-2 me-2 o_wforum_flag_mark_as_offensive" title="Mark as Offensive" data-bs-toggle="tooltip">
<i class="fa fa-times text-danger"/>
<a href="#" t-attf-data-action="/forum/#{object.id}/ask_for_mark_as_offensive" t-attf-class="o_wforum_flag_mark_as_offensive flag_validator #{'' if object.state == 'flagged' else 'd-none'} btn btn-danger my-2 me-2" title="Mark as Offensive" data-bs-toggle="tooltip" data-bs-placement="top">
<i class="fa fa-times"/> Reject
</a>
</template>
<template id="link_button">
<form t-attf-method="#{form_method or 'POST'}" t-att-action="url" t-attf-class="#{form_classes} #{not inDropdown and 'btn btn-sm border'}">
<form t-attf-method="#{form_method or 'POST'}" t-att-action="url" t-attf-class="#{form_classes} #{not inDropdown and ''}">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<div class="input-group">
<button t-attf-class="#{icon and not label and ('fa ' + icon)} #{inDropdown and 'dropdown-item ps-3' or 'btn btn-sm p-0'} #{classes} #{karma and 'karma_required text-muted'}" t-attf-data-karma="#{karma}" t-att-title="title">
<i t-if="icon and label" t-attf-class="fa fa-fw text-muted #{icon} #{inDropdown and 'me-1'}"/>
<t t-esc="label"/>
</button>
<t t-if="flag_validator" t-call="website_forum.show_flag_validator"/>
</div>
<button t-if="label" t-attf-class="#{icon and not label and ('fa ' + icon)} #{inDropdown and 'dropdown-item ps-3' or 'btn'} #{classes}#{karma > 0 and ' karma_required text-muted'}" t-attf-data-karma="#{karma}" t-att-title="title">
<i t-if="icon and label" t-attf-class="fa fa-fw #{icon} #{inDropdown and 'me-1'}"/>
<t t-out="label"/>
</button>
<t t-if="flag_validator" t-call="website_forum.show_flag_validator"/>
</form>
</template>
@@ -100,67 +98,70 @@
<template id="vote">
<t t-set="own_vote" t-value="post.user_vote"/>
<t t-set="forum" t-value="post.forum_id"/>
<!-- can_upvote returns "true" for unregistered users -->
<t t-set="can_upvote" t-value="post.can_upvote"/>
<t t-set="can_downvote" t-value="post.can_downvote"/>
<div t-attf-class="vote text-center d-inline-flex align-items-center #{vertical and 'o_wforum_vote_vertical flex-md-column'} #{classes}">
<div t-attf-class="vote d-inline-flex align-items-center ms-n2 #{classes} text-muted text-center">
<button type="button" t-attf-data-href="/forum/#{slug(forum)}/post/#{slug(post)}/upvote"
t-attf-class="btn btn-link vote_up fa fa-caret-up ps-0 #{vertical and 'px-md-0 pt-md-0' or 'pe-2'} #{own_vote == 1 and 'text-success' or 'text-muted'} #{not can_upvote and 'karma_required'}"
t-attf-class="btn vote_up #{own_vote == 1 and 'text-success'} #{not can_upvote and 'karma_required text-reset opacity-25'}#{post_id.create_uid.id == uid and ' text-300'}"
t-att-disabled="own_vote == 1 and 'disabled'"
t-att-data-karma="forum.karma_upvote"
t-att-data-can-upvote="can_upvote"
aria-label="Positive vote" title="Positive vote"/>
<b t-attf-class="vote_count #{own_vote == 1 and 'text-success' or (own_vote == -1 and 'text-danger' or 'text-muted')}"
t-esc="post.vote_count"/>
aria-label="Upvote" title="Upvote">
<i class="fa fa-caret-up" data-bs-toggle="tooltip" data-bs-placement="top" title="Upvote"/>
</button>
<small t-attf-class="vote_count #{own_vote == 1 and 'text-success' or (own_vote == -1 and 'text-danger') or post.vote_count == 0 and not own_vote and 'text-muted opacity-75'}"
t-out="post.vote_count"/>
<button type="button" t-attf-data-href="/forum/#{slug(forum)}/post/#{slug(post)}/downvote"
t-attf-class="btn btn-link vote_down fa fa-caret-down #{vertical and 'px-md-0' or 'px-2'} #{own_vote == -1 and 'text-danger' or 'text-muted'} #{not can_downvote and 'karma_required'}"
t-attf-class="btn vote_down #{own_vote == -1 and 'text-danger'} #{not can_downvote and 'karma_required text-reset opacity-25'}#{post_id.create_uid.id == uid and ' text-300'}"
t-att-disabled="own_vote == -1 and 'disabled'"
t-att-data-karma="forum.karma_downvote"
t-att-data-can-downvote="can_downvote"
aria-label="Negative vote" title="Negative vote"/>
aria-label="Downvote" title="Downvote">
<i class="fa fa-caret-down" data-bs-toggle="tooltip" data-bs-placement="top" title="Downvote"/>
</button>
<t t-out="0"/>
</div>
</template>
<template id="website_forum.author_box">
<template id="author_box">
<t t-set="display_info" t-value="show_name or show_date or show_karma"/>
<t t-if="allow_biography and object.can_display_biography" t-set="bio_popover_data">
<div class="d-flex o_wforum_bio_popover_wrap">
<img class="o_forum_avatar_big flex-shrink-0 me-3" t-att-src="website.image_url(object.create_uid, 'avatar_128', '75x75')" alt="Avatar"/>
<div class="o_wforum_bio_popover_wrap d-flex transition-fade">
<div class="d-flex flex-column">
<img class="o_wforum_avatar flex-shrink-0 rounded me-3" t-att-src="request.website.image_url(object.create_uid, 'avatar_128', '60x60')" alt="Avatar"/>
</div>
<div>
<h5 class="o_wforum_bio_popover_name mb-0" t-field="object.create_uid" t-options='{"widget": "contact", "country_image": True, "fields": ["name", "country_id"]}'/>
<h5 class="o_wforum_bio_popover_name mb-0" t-field="object.create_uid" t-options='{"widget": "contact", "country_image": True, "fields": ["name"]}'/>
<span class="o_wforum_bio_popover_info" t-field="object.create_uid" t-options='{"widget": "contact", "UserBio": True, "badges": True, "fields": ["karma"]}'/>
<div class="o_wforum_bio_popover_bio" t-field="object.create_uid" t-options='{"widget": "contact", "website_description": True, "fields": ["partner_id"]}'/>
<div class="o_wforum_bio_popover_bio mt-1 mb-0" t-field="object.create_uid" t-options='{"widget": "contact", "website_description": True, "fields": ["partner_id"]}'/>
</div>
</div>
</t>
<div t-attf-class="o_wforum_author_box d-inline-flex #{display_info and 'o_show_info'} #{compact and 'o_compact align-items-center'} #{bio_popover_data and 'o_wforum_bio_popover'}"
t-att-data-bs-content="bio_popover_data">
<t t-set="user_profile_url" t-valuef="#"/>
<t t-if="object.create_uid.id == request.session.uid or object.create_uid.sudo().website_published">
<t t-set="user_profile_url" t-value="'/forum/%s/user/%s' % (slug(forum), object.create_uid.id) + '?forum_origin=' + request.httprequest.path"/>
<div t-attf-class="o_wforum_author_box d-inline-flex #{display_info and 'o_show_info '}#{compact and 'o_compact align-items-center '}#{bio_popover_data and 'o_wforum_bio_popover '}#{_box_classes}"
t-att-data-bs-content="bio_popover_data"
data-bs-placement="top">
<t t-set="user_profile_url" t-value="False"/>
<t t-if="'forum_id' in object and (object.create_uid.id == request.session.uid or object.create_uid.sudo().website_published)">
<t t-set="user_profile_url" t-valuef="/forum/#{ slug(object.forum_id) }/user/#{ object.create_uid.id }?forum_origin=#{ request.httprequest.path }"/>
</t>
<a t-att-href="user_profile_url" class="o_wforum_author_pic position-relative rounded-circle">
<span t-if="object_validable" class="o_wforum_author_box_check rounded-circle bg-success position-absolute">
<i class="fa fa-check fa-fw small text-white"/>
</span>
<img t-attf-class="rounded-circle o_forum_avatar #{not display_info and 'shadow'}" t-att-src="website.image_url(object.create_uid, 'avatar_128', '40x40')" alt="Avatar"/>
<a t-if="show_image" t-att-href="user_profile_url or '#'" t-attf-class="o_wforum_author_pic position-relative #{ not user_profile_url and 'pe-none' }">
<img t-attf-class="o_wforum_avatar rounded-circle #{'me-2' if show_name or show_date or show_karma else '' } #{_image_classes}" t-att-src="website.image_url(object.create_uid, 'avatar_128', '40x40')" alt="Avatar"/>
</a>
<div t-if="show_name or show_date or show_karma" t-attf-class="d-flex #{compact and 'align-items-baseline' or 'flex-column justify-content-around'} #{show_image and 'ms-2'}">
<a t-att-href="user_profile_url" t-attf-class="my-0 text-reset h6 #{compact and 'small'}" t-field="object.create_uid" t-options='{"widget": "contact", "fields": ["name"]}'/>
<small t-if="show_karma and show_date" class="text-muted fw-bold"> - <t t-out="object.create_uid.karma"/>xp</small>
<div t-if="show_name or show_date or show_karma" t-attf-class="d-flex #{compact and 'align-items-baseline ms-1' or 'flex-column justify-content-around ms-2'}">
<a t-att-href="user_profile_url" class="h6 my-0 text-reset" t-field="object.create_uid" t-options='{"widget": "contact", "fields": ["name"]}'/>
<small t-if="show_karma and show_date" class="text-muted fw-bold"> - <t t-esc="object.create_uid.karma"/>xp</small>
<div t-attf-class="text-muted small fw-bold #{compact and 'd-flex align-items-baseline'}">
<span t-if="compact" class="mx-1"> - </span>
<time t-if="show_date" class="d-block text-muted fw-bold" t-field="object.create_date" t-options='{"format": "d MMMM y"}'/>
<span t-if="show_karma and not show_date" class="text-muted fw-bold"><t t-esc="object.create_uid.karma"/>xp</span>
<div t-if="show_date or show_karma" t-attf-class="text-muted small #{compact and 'd-flex align-items-baseline'}">
<div t-if="show_date">
<time class="text-muted" t-field="object.create_date" t-options="{ 'format': 'd MMMM y ' }"/>
at
<time class="text-muted" t-field="object.create_date" t-options="{ 'format': 'HH:mm ' }"/>
</div>
<span t-if="show_karma and not show_date" class="text-muted"><t t-out="object.create_uid.karma"/>xp</span>
</div>
</div>
</div>
</template>
</data>
</odoo>
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<template id="follow">
<div t-attf-class="js_follow #{div_class}"
t-att-data-id="object.id"
t-att-data-object="object._name"
t-att-data-follow="object.id and object.message_is_follower and 'on' or 'off'"
t-att-data-unsubscribe="'unsubscribe' if 'unsubscribe' in request.params else None">
<div t-if="icons_design and not request.env.user.has_group('base.group_public')" class="js_follow_icons_container">
<button t-attf-class="btn js_unfollow_btn #{btn_classes}">
<i t-attf-class="fa fa-fw fa-minus-circle #{icons_classes}" data-bs-toggle="tooltip" data-bs-placement="top" title="Unfollow"/>
</button>
<button t-attf-class="btn js_follow_btn #{btn_classes}">
<i t-attf-class="fa fa-fw fa-plus-circle #{icons_classes}" data-bs-toggle="tooltip" data-bs-placement="top" title="Follow"/>
</button>
</div>
<span t-elif="request.env.user.has_group('base.group_public') and icons_design" class="js_follow_icons_container">
<button t-attf-class="btn js_unfollow_btn #{btn_classes}">
<i t-attf-class="fa fa-fw fa-minus-circle #{icons_classes}" data-bs-toggle="tooltip" data-bs-placement="top" title="Unfollow"/>
</button>
<button t-attf-class="btn follow_btn #{btn_classes}" data-bs-toggle="modal" data-bs-target="#o_wmail_follow_modal">
<i t-attf-class="fa fa-fw fa-plus-circle #{icons_classes}" data-bs-toggle="tooltip" data-bs-placement="top" title="Follow"/>
</button>
</span>
<t t-else="">
<button href="#" t-attf-class="btn btn-primary js_unfollow_btn #{btn_classes}"><i class="fa fa-fw fa-check me-1"/>Following</button>
<button href="#" t-attf-class="btn btn-outline-primary js_follow_btn #{btn_classes}">Follow</button>
</t>
<div role="dialog" class="modal fade" id="o_wmail_follow_modal" aria-hidden="True">
<div class="modal-dialog mw-lg-25" role="document">
<div class="modal-content">
<header class="modal-header" role="status">
<h4 class="modal-title">Subscribe</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</header>
<main class="modal-body">
<p t-if="object == question">Get notified when there's activity on this post</p>
<p t-if="object == tag">Get notified when this tag is used</p>
<input type="email" name="email"
class="js_follow_email form-control mb-2"
placeholder="your email..."
groups="base.group_public"/>
<button href="#" t-attf-class="btn btn-primary js_follow_btn">Subscribe</button>
<button href="#" t-attf-class="btn btn-secondary js_unfollow_btn"><i class="fa fa-fw fa-check me-1"/>Following</button>
</main>
</div>
</div>
</div>
</div>
</template>
</odoo>
@@ -4,7 +4,7 @@
<template id="private_profile" inherit_id="website_profile.private_profile">
<xpath expr="//div[@id='private_profile_return_link_container']" position="inside">
<t t-if="request.params.get('forum_id')">
<a t-attf-href="/forum/#{request.params.get('forum_id')}">Return to the forum.</a>
<a t-attf-href="/forum/#{request.params.get('forum_id')}" class="btn btn-primary">Return to the forum</a>
</t>
</xpath>
</template>
@@ -28,9 +28,9 @@
<td>
<span>
<i class="fa fa-thumbs-up text-success" role="img" aria-label="Positive votes" title="Positive votes"/>
<span class="fw-bold" t-esc="up_votes"/>
<span class="fw-bold" t-out="up_votes"/>
<i class="fa fa-thumbs-down text-danger ms-3" role="img" aria-label="Negative votes" title="Negative votes"/>
<span class="fw-bold" t-esc="down_votes"/>
<span class="fw-bold" t-out="down_votes"/>
</span>
</td>
</tr>
@@ -39,10 +39,10 @@
<xpath expr="//ul[@id='profile_extra_info_tablist']" position="inside">
<t t-if="forum">
<li class="nav-item">
<a role="tab" aria-controls="questions" href="#questions" class="nav-link o_wprofile_navlink" data-bs-toggle="tab"><t t-esc="count_questions"/> Questions</a>
<a role="tab" aria-controls="questions" href="#questions" class="nav-link o_wprofile_navlink" data-bs-toggle="tab"><t t-out="count_questions"/> Questions</a>
</li>
<li class="nav-item">
<a role="tab" aria-controls="answers" href="#answers" class="nav-link o_wprofile_navlink" data-bs-toggle="tab"><t t-esc="count_answers"/> Answers</a>
<a role="tab" aria-controls="answers" href="#answers" class="nav-link o_wprofile_navlink" data-bs-toggle="tab"><t t-out="count_answers"/> Answers</a>
</li>
<li t-if="uid == user.id" class="nav-item">
<a role="tab" aria-controls="activity" href="#activity" class="nav-link o_wprofile_navlink" data-bs-toggle="tab">Activity</a>
@@ -54,6 +54,7 @@
</xpath>
<xpath expr="//div[@id='profile_extra_info_tabcontent']" position="inside">
<t t-if="forum">
<t t-set="is_profile" t-value="true"/>
<div role="tabpanel" class="o_wforum_profile_tab tab-pane" id="questions">
<div class="mb-4">
<h5 class="border-bottom pb-1">
@@ -65,8 +66,18 @@
<t t-call="website_forum.display_post"/>
</div>
</t>
<div t-elif="my_profile" class="text-muted">
You have not posted any questions yet. <br />
<a href="/forum/" class="btn btn-link px-0">
<i class="oi oi-arrow-right d-inline-block"/> Go to Forums
</a>
</div>
<t t-else="">
<span class="fw-bold">No question posted yet.</span>
<div class="text-muted">This user hasn't posted any questions yet.<br/>
<a href="/forum/" class="btn btn-link px-0">
<i class="oi oi-arrow-right d-inline-block"/> Go to Forums
</a>
</div>
</t>
</div>
<t t-if="favourite">
@@ -100,9 +111,18 @@
<t t-call="website_forum.display_post_answer"/>
</div>
</t>
<t t-else="">
<span class="fw-bold">No answer posted yet.</span>
</t>
<div t-elif="my_profile" class="text-muted">
You have not answered any questions yet. <br />
<a href="/forum/" class="btn btn-link px-0">
<i class="oi oi-arrow-right d-inline-block"/> Go to Forums
</a>
</div>
<div t-else="" class="text-muted">
This user hasn't answered any questions yet. <br/>
<a href="/forum/" class="btn btn-link px-0">
<i class="oi oi-arrow-right d-inline-block"/> Go to Forums
</a>
</div>
</div>
</div>
<div role="tabpanel" class="o_wforum_profile_tab tab-pane" id="votes" t-if="uid == user.id">
@@ -126,10 +146,10 @@
<template id="forum_filter_tag" name="Filtering Forum Tag">
<t t-if="forum_filtered">
<span class="align-items-baseline border d-inline-flex ps-2 rounded mb-1 ms-4">
<i class="fa fa-filter me-2 text-muted"/>
<t t-esc="forum_filtered"/>
<a t-attf-href="/profile/user/#{uid}" class="btn border-0 py-1">&#215;</a>
<span class="d-inline-flex align-items-center gap-2 rounded py-1 ps-2 pe-0 bg-200 small">
<i class="fa fa-filter text-muted"/>
<t t-out="forum_filtered"/>
<a t-attf-href="/profile/user/#{uid}" class="oi oi-close btn m-n1 text-muted"/>
</span>
</t>
</template>
@@ -138,41 +158,41 @@
<t t-if="activities">
<div t-foreach="activities" t-as="activity" class="card mb-2">
<div class="card-body">
<span t-esc="activity.subtype_id.name" class="badge text-bg-info me-2 mt-1"/>
<span t-out="activity.subtype_id.name" class="badge text-bg-info me-2 mt-1"/>
<span t-field="activity.date" t-options='{"format": "short"}' class="me-2"/>
<t t-set="post" t-value="posts[activity.res_id]"/>
<span t-if="post[1]">
<a t-attf-href="/forum/#{ slug(post[0].forum_id) }/#{ slug(post[0]) }#answer-#{ str(post[1].id) }">
<span t-esc="post[0].name"/>
<span t-out="post[0].name"/>
</a>
</span>
<span t-if="not post[1]">
<a t-attf-href="/forum/#{ slug(post[0].forum_id) }/#{ slug(post[0]) }">
<span t-esc="post[0].name"/>
<span t-out="post[0].name"/>
</a>
</span>
</div>
</div>
</t>
<t t-else="">
<p class="text-muted">No activities yet!</p>
<span class="text-muted">There is no activity yet.</span>
</t>
</template>
<template id="user_votes" name="Forum User Votes">
<div t-foreach="vote_post" t-as="vote">
<span t-field="vote.post_id.create_date"/>
<span t-if="vote.vote == '1'" class="fa fa-thumbs-up text-success" style="margin-left:30px" role="img" aria-label="Positive vote" title="Positive vote"/>
<span t-if="vote.vote == '-1'" class="fa fa-thumbs-down text-warning" style="margin-left:30px" role="img" aria-label="Negative vote" title="Negative vote"/>
<t t-if="vote.post_id.parent_id">
<a t-attf-href="/forum/#{ slug(vote.post_id.forum_id) }/#{ vote.post_id.parent_id.id }/#answer-#{ vote.post_id.id }" t-esc="vote.post_id.parent_id.name" style="margin-left:10px"/>
</t>
<t t-if="not vote.post_id.parent_id">
<a t-attf-href="/forum/#{ slug(vote.post_id.forum_id) }/#{ vote.post_id.id }" style=" color:black;margin-left:10px" t-esc="vote.post_id.name"/>
</t>
<div t-foreach="vote_post" t-as="vote" class="card mb-2">
<div class="card-body">
<span t-field="vote.post_id.create_date"/>
<span t-if="vote.vote == '1'" class="fa fa-thumbs-up text-success" style="margin-left:30px" role="img" aria-label="Positive vote" title="Positive vote"/>
<span t-if="vote.vote == '-1'" class="fa fa-thumbs-down text-warning" style="margin-left:30px" role="img" aria-label="Negative vote" title="Negative vote"/>
<a t-if="vote.post_id.parent_id" class="ms-2" t-attf-href="/forum/#{ slug(vote.post_id.forum_id) }/#{ vote.post_id.parent_id.id }/#answer-#{ vote.post_id.id }"
t-out="vote.post_id.parent_id.name"/>
<a t-else="" class="ms-2 text-black" t-attf-href="/forum/#{ slug(vote.post_id.forum_id) }/#{ vote.post_id.id }"
t-out="vote.post_id.name"/>
</div>
</div>
<div class="mb16" t-if="not vote_post">
<p class="text-muted">No vote given by you yet!</p>
<p class="text-muted">You haven't given any votes yet.</p>
</div>
</template>
</data></odoo>
@@ -1,10 +1,14 @@
.js_follow[data-follow='on'] .js_follow_btn ,
.js_follow[data-follow='on'] .js_follow_btn, .js_follow[data-follow='on'] .follow_btn,
.js_follow[data-follow='off'] .js_unfollow_btn {
display: none;
}
.js_follow[data-follow='on'] .js_unfollow_btn {
opacity: 1 !important;
}
.js_follow_icons_container {
.js_follow_btn, .js_unfollow_btn {
.js_follow_btn, .js_unfollow_btn, .follow_btn {
animation: js_follow_fade 1s ease forwards;
opacity: 0;
@@ -18,12 +22,11 @@
transition-duration: 1s;
opacity: 1;
}
};
}
}
.fa:before {
content: "\f0f3";
color: $text-muted;
}
.js_follow_btn:hover .fa:before {
@@ -31,12 +34,12 @@
}
.js_unfollow_btn .fa:before {
color: map-get($theme-colors, 'primary');
color: $primary;
}
.js_unfollow_btn:hover .fa:before {
content: "\f1f6";
color: map-get($theme-colors, 'danger');
color: $danger;
}
}
@@ -96,7 +96,7 @@ publicWidget.registry.websiteProfileEditor = publicWidget.Widget.extend({
var reader = new window.FileReader();
reader.readAsDataURL(ev.currentTarget.files[0]);
reader.onload = function (ev) {
$form.find('.o_forum_avatar_img').attr('src', ev.target.result);
$form.find('.o_wforum_avatar_img').attr('src', ev.target.result);
};
$form.find('#forum_clear_image').remove();
},
@@ -106,7 +106,7 @@ publicWidget.registry.websiteProfileEditor = publicWidget.Widget.extend({
*/
_onProfilePicClearClick: function (ev) {
var $form = $(ev.currentTarget).closest('form');
$form.find('.o_forum_avatar_img').attr('src', '/web/static/img/placeholder.png');
$form.find('.o_wforum_avatar_img').attr('src', '/web/static/img/placeholder.png');
$form.append($('<input/>', {
name: 'clear_image',
id: 'forum_clear_image',
@@ -89,7 +89,7 @@
<div class="col-3">
<div class="card o_card_people">
<div class="card-body">
<img class="o_forum_avatar_img w-100 mb-3" t-att-src="website.image_url(user, 'avatar_128')"/>
<img class="o_wforum_avatar_img w-100 mb-3" t-att-src="website.image_url(user, 'avatar_128')"/>
<div class="text-center">
<a href="#" class="o_forum_profile_pic_edit btn btn-primary" aria-label="Edit">
<i class="fa fa-pencil fa-1g float-sm-none float-md-start" title="Edit"></i>
@@ -10,6 +10,7 @@ class Forum(models.Model):
slide_channel_ids = fields.One2many('slide.channel', 'forum_id', 'Courses', help="Edit the course linked to this forum on the course form.")
slide_channel_id = fields.Many2one('slide.channel', 'Course', compute='_compute_slide_channel_id', store=True)
visibility = fields.Selection(related='slide_channel_id.visibility', help="Forum linked to a Course, the visibility is the one applied on the course.")
image_1920 = fields.Image('Image', compute='_compute_image_1920', store=True, readonly=False)
@api.depends('slide_channel_ids')
def _compute_slide_channel_id(self):
@@ -18,3 +19,8 @@ class Forum(models.Model):
forum.slide_channel_id = forum.slide_channel_ids[0]
else:
forum.slide_channel_id = None
@api.depends('slide_channel_id', 'slide_channel_id.image_1920')
def _compute_image_1920(self):
for forum in self.filtered(lambda f: not f.image_1920 and f.slide_channel_id.image_1920):
forum.image_1920 = forum.slide_channel_id.image_1920
@@ -1,81 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data>
<template id="website_slides_forum_header" inherit_id="website_forum.header">
<xpath expr="//div[hasclass('o_forum_nav_header_container')]" position="before">
<t t-if="forum.slide_channel_id">
<div class="o_wforum_elearning_navtabs_container">
<div class="container">
<ul class="nav nav-tabs o_wprofile_nav_tabs mt-0 flex-nowrap" role="tablist" id="profile_extra_info_tablist">
<li class="nav-item">
<a t-att-href="'/slides/%s%s' % (slug(forum.slide_channel_id), '/%s' % slug(category) if category else '')"
t-att-class="'nav-link o_wprofile_navlink'" style="border-left: 0px">
<i class="fa fa-home"/> Course</a>
</li>
<li t-if="forum.slide_channel_id.allow_comment" class="nav-item">
<a t-att-href="'/slides/%s?active_tab=review' % (slug(forum.slide_channel_id))"
t-att-class="'nav-link o_wprofile_navlink'" style="border-left: 0px">
Reviews<t t-if="forum.slide_channel_id.rating_count"> (<t t-esc="forum.slide_channel_id.rating_count"/>)</t>
</a>
</li>
<li class="nav-item">
<a t-att-href="'/forum/%s' % (slug(forum))" t-att-class="'nav-link active o_wprofile_navlink'" style="border-left: 0px">Forum</a>
</li>
</ul>
</div>
</div>
</t>
</xpath>
</template>
<template name="Split regular forums/courses" id="website_slides_forum_index" inherit_id="website_forum.forum_all" active="True">
<xpath expr="//div[@id='o_wforum_forums_index_list']/*[@t-if='forums']" position="replace">
<xpath expr="//div[@id='o_wforum_forums_index_list']//*[@t-if='forums']" position="replace">
<t t-if="forums">
<t t-set="courses_discussions" t-value="forums.filtered(lambda f: f.slide_channel_id)"/>
<t t-set="regular_forums" t-value="forums - courses_discussions"/>
<div class="row mb-4">
<t t-if="len(regular_forums) == 1">
<t t-set="forum" t-value="regular_forums"/>
<div class="col-md-10 col-lg-8 mb-2">
<div class="row align-items-start">
<div class="col-12 col-sm-4">
<a t-attf-href="/forum/#{slug(forum)}">
<div t-attf-class="h-100 w-100 o_editable #{not forum.image_1920 and 'rounded o_wforum_forum_card_bg shadow-sm flex-shrink-0'}">
<div t-if="forum.image_1920 or editable" t-attf-class="h-100"
t-field="forum.image_1920" t-options="{'widget': 'image', 'preview_image': 'image_256', 'class': 'w-100 h-100 o_object_fit_cover rounded'}" />
</div>
</a>
</div>
<div class="col-12 col-sm-7 mt-2 mt-sm-0">
<a t-attf-href="/forum/#{slug(forum)}" class="text-reset" t-att-title="forum.name">
<h3 class="h2" t-field="forum.name"/>
</a>
<p t-attf-class="m-0 lead #{not forum.description and 'css_non_editable_mode_hidden'}"
placeholder="Description"
t-field="forum.teaser"/>
</div>
</div>
</div>
</t>
<t t-else="">
<t t-call="website_forum.forum_all_all_entries">
<t t-set="_forums" t-value="regular_forums"/>
</t>
</t>
</div>
<div class="d-flex border-bottom pb-1 pt-3 mb-3">
<h2 class="h4 mb-0 text-muted">Courses Discussions</h2>
<div class="flex-grow-1 text-end">
<a href="/slides" title="See all Courses">Check our Courses <i class="oi oi-chevron-right"/></a>
</div>
</div>
<div class="row">
<t t-call="website_forum.forum_all_all_entries">
<t t-set="_forums" t-value="courses_discussions"/>
</t>
</div>
<t t-call="website_forum.forum_all_all_entries">
<t t-set="_forums" t-value="regular_forums"/>
</t>
<t t-call="website_forum.forum_all_all_entries">
<t t-set="_forums" t-value="courses_discussions"/>
</t>
</t>
</xpath>
</template>
</data></odoo>
<template name="Replace Breadcrumb Root" id="website_slides_forum_breadcrumb" inherit_id="website_forum.forum_model_nav" active="True">
<xpath expr="//nav[@id='o_wforum_nav']" position="before">
<t t-if="forum.slide_channel_id" t-set="breadcrumb_kind" t-value="'slides'"/>
</xpath>
<xpath expr="//div[hasclass('o_wforum_breadcrumb_root_single')]" postition="inside">
<ol t-if="breadcrumb_kind == 'slides'" class="breadcrumb col-10 col-lg-6 mb-0 p-0">
<li class="breadcrumb-item">
<h5 class="mb-0">
<a t-attf-href="/slides/#{slug(forum.slide_channel_id)}#{'/' + slug(category) if category else ''}" t-out="forum.name"/>
</h5>
</li>
<li class="breadcrumb-item text-nowrap">
<h5 class="mb-0"><a t-attf-href="/forum/#{ slug(forum) }">Forum</a></h5>
</li>
</ol>
</xpath>
<xpath expr="//div[hasclass('o_wforum_breadcrumb_root_list_or_edit')]" postition="inside">
<ol t-if="breadcrumb_kind == 'slides'" class="breadcrumb col-10 col-lg flex-grow-1 mb-0 p-0">
<li class="breadcrumb-item text-nowrap">
<h5 class="mb-0">
<a t-attf-href="/slides/#{slug(forum.slide_channel_id)}#{'/' + slug(category) if category else ''}" t-out="forum.name"/>
</h5>
</li>
<li class="breadcrumb-item text-nowrap">
<h5 class="mb-0"><strong>Forum</strong></h5>
</li>
</ol>
</xpath>
</template>
<template name="Add course badge" id="forum_all_all_entries" inherit_id="website_forum.forum_all_all_entries">
<xpath expr="//t[@t-foreach='sorted_forums']" position="before">
<t t-if="_forums == courses_discussions">
<t t-set="forum_badge">Course</t>
</t>
</xpath>
<xpath expr="//t[@t-foreach='sorted_forums']" position="after">
<div t-if="_forums == courses_discussions" class="d-flex justify-content-center">
<a href="/slides" class="btn btn-link">Check out our courses <i class="fa fa-long-arrow-right"/></a>
</div>
</xpath>
</template>
</data>
</odoo>