Начальное наполнение

This commit is contained in:
parent be57b68e14
commit 875e1038b3
132 changed files with 243665 additions and 0 deletions

5
__init__.py Normal file
View File

@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
from . import controllers
from . import models
from . import populate

82
__manifest__.py Normal file
View File

@ -0,0 +1,82 @@
# -*- 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.2',
'description': """
Ask questions, get answers, no distractions
""",
'website': 'https://www.odoo.com/app/forum',
'depends': [
'auth_signup',
'website_mail',
'website_profile',
],
'data': [
'data/ir_config_parameter_data.xml',
'data/forum_forum_template_faq.xml',
'data/forum_forum_data.xml',
'data/forum_post_reason_data.xml',
'data/ir_actions_data.xml',
'data/mail_message_subtype_data.xml',
'data/mail_templates.xml',
'data/website_menu_data.xml',
'views/forum_post_views.xml',
'views/forum_post_reason_views.xml',
'views/forum_tag_views.xml',
'views/forum_forum_views.xml',
'views/res_users_views.xml',
'views/gamification_karma_tracking_views.xml',
'views/forum_menus.xml',
'views/base_contact_templates.xml',
'views/forum_forum_templates.xml',
'views/forum_forum_templates_forum_all.xml',
'views/forum_forum_templates_layout.xml',
'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',
'security/ir.model.access.csv',
'security/ir_rule_data.xml',
'data/gamification_badge_data_question.xml',
'data/gamification_badge_data_answer.xml',
'data/gamification_badge_data_participation.xml',
'data/gamification_badge_data_moderation.xml',
],
'demo': [
'data/forum_tag_demo.xml',
'data/forum_post_demo.xml',
],
'installable': True,
'assets': {
'website.assets_editor': [
'website_forum/static/src/js/systray_items/*.js',
],
'web.assets_tests': [
'website_forum/static/tests/**/*',
],
'web.assets_backend': [
'website_forum/static/src/js/tours/website_forum.js',
],
'web.assets_frontend': [
'website_forum/static/src/js/tours/website_forum.js',
'website_forum/static/src/scss/website_forum.scss',
'website_forum/static/src/js/website_forum.js',
'website_forum/static/src/js/website_forum.share.js',
'website_forum/static/src/xml/public_templates.xml',
'website_forum/static/src/components/flag_mark_as_offensive/**/*',
],
},
'license': 'LGPL-3',
}

3
controllers/__init__.py Normal file
View File

@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import website_forum

View File

@ -0,0 +1,776 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import logging
import lxml
import requests
import werkzeug.exceptions
import werkzeug.urls
import werkzeug.wrappers
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.exceptions import AccessError, UserError
from odoo.http import request
from odoo.osv import expression
_logger = logging.getLogger(__name__)
class WebsiteForum(WebsiteProfile):
_post_per_page = 10
_user_per_page = 30
def _prepare_user_values(self, **kwargs):
values = super(WebsiteForum, self)._prepare_user_values(**kwargs)
values['forum_welcome_message'] = request.httprequest.cookies.get('forum_welcome_message', False)
values.update({
'header': kwargs.get('header', dict()),
'searches': kwargs.get('searches', dict()),
})
if kwargs.get('forum'):
values['forum'] = kwargs.get('forum')
elif kwargs.get('forum_id'):
values['forum'] = request.env['forum.forum'].browse(int(kwargs.pop('forum_id')))
forum = values.get('forum')
if forum and forum is not True and not request.env.user._is_public():
def _get_my_other_forums():
post_domain = expression.OR(
[[('create_uid', '=', request.uid)],
[('favourite_ids', '=', request.uid)]]
)
return request.env['forum.forum'].search(expression.AND([
request.website.website_domain(),
[('id', '!=', forum.id)],
[('post_ids', 'any', post_domain)]
]))
values['my_other_forums'] = tools.lazy(_get_my_other_forums)
else:
values['my_other_forums'] = request.env['forum.forum']
return values
def _prepare_mark_as_offensive_values(self, post, **kwargs):
offensive_reasons = request.env['forum.post.reason'].search([('reason_type', '=', 'offensive')])
values = self._prepare_user_values(**kwargs)
values.update({
'question': post,
'forum': post.forum_id,
'reasons': offensive_reasons,
'offensive': True,
})
return values
# Forum
# --------------------------------------------------
@http.route(['/forum'], type='http', auth="public", website=True, sitemap=True)
def forum(self, **kwargs):
domain = request.website.website_domain()
forums = request.env['forum.forum'].search(domain)
if len(forums) == 1:
return request.redirect('/forum/%s' % slug(forums[0]), code=302)
return request.render("website_forum.forum_all", {
'forums': forums
})
def sitemap_forum(env, rule, qs):
Forum = env['forum.forum']
dom = sitemap_qs2dom(qs, '/forum', Forum._rec_name)
dom += env['website'].get_current_website().website_domain()
for f in Forum.search(dom):
loc = '/forum/%s' % slug(f)
if not qs or qs.lower() in loc:
yield {'loc': loc}
def _get_forum_post_search_options(self, forum=None, tag=None, filters=None, my=None, create_uid=False, include_answers=False, **post):
return {
'allowFuzzy': not post.get('noFuzzy'),
'create_uid': create_uid,
'displayDescription': False,
'displayDetail': False,
'displayExtraDetail': False,
'displayExtraLink': False,
'displayImage': False,
'filters': filters,
'forum': str(forum.id) if forum else None,
'include_answers': include_answers,
'my': my,
'tag': str(tag.id) if tag else None,
}
@http.route(['/forum/all',
'/forum/all/page/<int:page>',
'/forum/<model("forum.forum"):forum>',
'/forum/<model("forum.forum"):forum>/page/<int:page>',
'''/forum/<model("forum.forum"):forum>/tag/<model("forum.tag"):tag>/questions''',
'''/forum/<model("forum.forum"):forum>/tag/<model("forum.tag"):tag>/questions/page/<int:page>''',
], type='http', auth="public", website=True, sitemap=sitemap_forum)
def questions(self, forum=None, tag=None, page=1, filters='all', my=None, sorting=None, search='', create_uid=False, include_answers=False, **post):
Post = request.env['forum.post']
author = request.env['res.users'].browse(int(create_uid))
if author == request.env.user:
my = 'mine'
if sorting:
# check that sorting is valid
# retro-compatibility for V8 and google links
try:
sorting = werkzeug.urls.url_unquote_plus(sorting)
Post._order_to_sql(sorting, None)
except (UserError, ValueError):
sorting = False
if not sorting:
sorting = forum.default_order if forum else 'last_activity_date desc'
options = self._get_forum_post_search_options(
forum=forum,
tag=tag,
filters=filters,
my=my,
create_uid=author.id,
include_answers=include_answers,
my_profile=request.env.user == author,
**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_ids = details[0].get('results', Post)
question_ids = question_ids[(page - 1) * self._post_per_page:page * self._post_per_page]
if not forum:
url = '/forum/all'
else:
url = f"/forum/{slug(forum)}{f'/tag/{slug(tag)}/questions' if tag else ''}"
url_args = {'sorting': sorting}
for name, value in zip(['filters', 'search', 'my'], [filters, search, my]):
if value:
url_args[name] = value
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({
'author': author,
'edit_in_backend': True,
'question_ids': question_ids,
'question_count': question_count,
'search_count': question_count,
'pager': pager,
'tag': tag,
'filters': filters,
'my': my,
'sorting': sorting,
'search': fuzzy_search_term or search,
'original_search': fuzzy_search_term and search,
})
if forum or tag:
values['main_object'] = tag or forum
return request.render("website_forum.forum_index", values)
@http.route(['''/forum/<model("forum.forum"):forum>/faq'''], type='http', auth="public", website=True, sitemap=True)
def forum_faq(self, forum, **post):
values = self._prepare_user_values(forum=forum, searches=dict(), header={'is_guidelines': True}, **post)
return request.render("website_forum.faq", values)
@http.route(['/forum/<model("forum.forum"):forum>/faq/karma'], type='http', auth="public", website=True, sitemap=False)
def forum_faq_karma(self, forum, **post):
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(
domain=[('forum_id', '=', int(forum_id)), ('name', '=ilike', (query or '') + "%")],
fields=['id', 'name'],
limit=int(limit),
)
return request.make_response(
json.dumps(data),
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='', filters='all', search='', **post):
"""Render a list of tags matching filters and search parameters.
: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))
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({
'active_char_tag': tag_char.lower(),
'pager_tag_chars': first_char_list,
'search_count': len(tags) if search else None,
'tags': tags,
})
return request.render("website_forum.forum_index_tags", values)
# Questions
# --------------------------------------------------
@http.route('/forum/get_url_title', type='json', auth="user", methods=['POST'], website=True)
def get_url_title(self, **kwargs):
try:
req = requests.get(kwargs.get('url'))
req.raise_for_status()
arch = lxml.html.fromstring(req.content)
return arch.find(".//title").text
except IOError:
return False
@http.route(['''/forum/<model("forum.forum"):forum>/question/<model("forum.post", "[('forum_id','=',forum.id),('parent_id','=',False),('can_view', '=', True)]"):question>'''],
type='http', auth="public", website=True, sitemap=False)
def old_question(self, forum, question, **post):
# Compatibility pre-v14
return request.redirect(_build_url_w_params("/forum/%s/%s" % (slug(forum), slug(question)), request.params), code=301)
@http.route(['''/forum/<model("forum.forum"):forum>/<model("forum.post", "[('forum_id','=',forum.id),('parent_id','=',False),('can_view', '=', True)]"):question>'''],
type='http', auth="public", website=True, sitemap=True)
def question(self, forum, question, **post):
if not forum.active:
return request.render("website_forum.header", {'forum': forum})
# Hide posts from abusers (negative karma), except for moderators
if not question.can_view:
raise werkzeug.exceptions.NotFound()
# Hide pending posts from non-moderators and non-creator
user = request.env.user
if question.state == 'pending' and user.karma < forum.karma_post and question.create_uid != user:
raise werkzeug.exceptions.NotFound()
if question.parent_id:
redirect_url = "/forum/%s/%s" % (slug(forum), slug(question.parent_id))
return request.redirect(redirect_url, 301)
filters = 'question'
values = self._prepare_user_values(forum=forum, searches=post)
values.update({
'main_object': question,
'edit_in_backend': True,
'question': question,
'header': {'question_data': True},
'filters': filters,
'reversed': reversed,
})
if (request.httprequest.referrer or "").startswith(request.httprequest.url_root):
values['has_back_button_url'] = True
# increment view counter
question.sudo()._set_viewed()
return request.render("website_forum.post_description_full", values)
@http.route('/forum/<model("forum.forum"):forum>/question/<model("forum.post"):question>/toggle_favourite', type='json', auth="user", methods=['POST'], website=True)
def question_toggle_favorite(self, forum, question, **post):
if not request.session.uid:
return {'error': 'anonymous_user'}
favourite = not question.user_favourite
question.sudo().favourite_ids = [(favourite and 4 or 3, request.uid)]
if favourite:
# Automatically add the user as follower of the posts that he
# favorites (on unfavorite we chose to keep him as a follower until
# he decides to not follow anymore).
question.sudo().message_subscribe(request.env.user.partner_id.ids)
return favourite
@http.route('/forum/<model("forum.forum"):forum>/question/<model("forum.post"):question>/ask_for_close', type='http', auth="user", methods=['POST'], website=True)
def question_ask_for_close(self, forum, question, **post):
reasons = request.env['forum.post.reason'].search([('reason_type', '=', 'basic')])
values = self._prepare_user_values(**post)
values.update({
'question': question,
'forum': forum,
'reasons': reasons,
})
return request.render("website_forum.close_post", values)
@http.route('/forum/<model("forum.forum"):forum>/question/<model("forum.post"):question>/edit_answer', type='http', auth="user", website=True)
def question_edit_answer(self, forum, question, **kwargs):
for record in question.child_ids:
if record.create_uid.id == request.uid:
answer = record
break
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):
question.close(reason_id=int(post.get('reason_id', False)))
return request.redirect("/forum/%s/question/%s" % (slug(forum), slug(question)))
@http.route('/forum/<model("forum.forum"):forum>/question/<model("forum.post"):question>/reopen', type='http', auth="user", methods=['POST'], website=True)
def question_reopen(self, forum, question, **kwarg):
question.reopen()
return request.redirect("/forum/%s/%s" % (slug(forum), slug(question)))
@http.route('/forum/<model("forum.forum"):forum>/question/<model("forum.post"):question>/delete', type='http', auth="user", methods=['POST'], website=True)
def question_delete(self, forum, question, **kwarg):
question.active = False
return request.redirect("/forum/%s" % slug(forum))
@http.route('/forum/<model("forum.forum"):forum>/question/<model("forum.post"):question>/undelete', type='http', auth="user", methods=['POST'], website=True)
def question_undelete(self, forum, question, **kwarg):
question.active = True
return request.redirect("/forum/%s/%s" % (slug(forum), slug(question)))
# Post
# --------------------------------------------------
@http.route(['/forum/<model("forum.forum"):forum>/ask'], type='http', auth="user", website=True)
def forum_post(self, forum, **post):
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={}, new_question=True)
return request.render("website_forum.new_question", values)
@http.route(['/forum/<model("forum.forum"):forum>/new',
'/forum/<model("forum.forum"):forum>/<model("forum.post"):post_parent>/reply'],
type='http', auth="user", methods=['POST'], website=True)
def post_create(self, forum, post_parent=None, **post):
if post.get('content', '') == '<p><br></p>':
return request.render('http_routing.http_error', {
'status_code': _('Bad Request'),
'status_message': post_parent and _('Reply should not be empty.') or _('Question should not be empty.')
})
post_tag_ids = forum._tag_to_write_vals(post.get('post_tags', ''))
if forum.has_pending_post:
return request.redirect("/forum/%s/ask" % slug(forum))
new_question = request.env['forum.post'].create({
'forum_id': forum.id,
'name': post.get('post_name') or (post_parent and 'Re: %s' % (post_parent.name or '')) or '',
'content': post.get('content', False),
'parent_id': post_parent and post_parent.id or False,
'tag_ids': post_tag_ids
})
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 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'])
post.with_context(mail_create_nosubscribe=True).message_post(
body=body,
message_type='comment',
subtype_xmlid='mail.mt_comment')
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'}
# set all answers to False, only one can be accepted
(post.parent_id.child_ids - post).write(dict(is_correct=False))
post.is_correct = not post.is_correct
return post.is_correct
@http.route('/forum/<model("forum.forum"):forum>/post/<model("forum.post"):post>/delete', type='http', auth="user", methods=['POST'], website=True)
def post_delete(self, forum, post, **kwargs):
question = post.parent_id
post.unlink()
if question:
request.redirect("/forum/%s/%s" % (slug(forum), slug(question)))
return request.redirect("/forum/%s" % slug(forum))
@http.route('/forum/<model("forum.forum"):forum>/post/<model("forum.post"):post>/edit', type='http', auth="user", website=True)
def post_edit(self, forum, post, **kwargs):
tags = [dict(id=tag.id, name=tag.name) for tag in post.tag_ids]
tags = json.dumps(tags)
values = self._prepare_user_values(forum=forum)
values.update({
'tags': tags,
'post': post,
'is_edit': True,
'is_answer': bool(post.parent_id),
'searches': kwargs,
'content': post.name,
})
return request.render("website_forum.edit_post", values)
@http.route('/forum/<model("forum.forum"):forum>/post/<model("forum.post"):post>/save', type='http', auth="user", methods=['POST'], website=True)
def post_save(self, forum, post, **kwargs):
vals = {
'content': kwargs.get('content'),
}
if 'post_name' in kwargs:
if not kwargs.get('post_name').strip():
return request.render('http_routing.http_error', {
'status_code': _('Bad Request'),
'status_message': _('Title should not be empty.')
})
vals['name'] = kwargs.get('post_name')
vals['tag_ids'] = forum._tag_to_write_vals(kwargs.get('post_tags', ''))
post.write(vals)
question = post.parent_id if post.parent_id else post
return request.redirect("/forum/%s/%s" % (slug(forum), slug(question)))
# JSON utilities
# --------------------------------------------------
@http.route('/forum/<model("forum.forum"):forum>/post/<model("forum.post"):post>/upvote', type='json', auth="public", website=True)
def post_upvote(self, forum, post, **kwargs):
if not request.session.uid:
return {'error': 'anonymous_user'}
if request.uid == post.create_uid.id:
return {'error': 'own_post'}
upvote = True if not post.user_vote > 0 else False
return post.vote(upvote=upvote)
@http.route('/forum/<model("forum.forum"):forum>/post/<model("forum.post"):post>/downvote', type='json', auth="public", website=True)
def post_downvote(self, forum, post, **kwargs):
if not request.session.uid:
return {'error': 'anonymous_user'}
if request.uid == post.create_uid.id:
return {'error': 'own_post'}
upvote = True if post.user_vote < 0 else False
return post.vote(upvote=upvote)
# Moderation Tools
# --------------------------------------------------
@http.route('/forum/<model("forum.forum"):forum>/validation_queue', type='http', auth="user", website=True)
def validation_queue(self, forum, **kwargs):
user = request.env.user
if user.karma < forum.karma_moderate:
raise werkzeug.exceptions.NotFound()
Post = request.env['forum.post']
domain = [('forum_id', '=', forum.id), ('state', '=', 'pending')]
posts_to_validate_ids = Post.search(domain)
values = self._prepare_user_values(forum=forum)
values.update({
'posts_ids': posts_to_validate_ids.sudo(),
'queue_type': 'validation',
})
return request.render("website_forum.moderation_queue", values)
@http.route('/forum/<model("forum.forum"):forum>/flagged_queue', type='http', auth="user", website=True)
def flagged_queue(self, forum, **kwargs):
user = request.env.user
if user.karma < forum.karma_moderate:
raise werkzeug.exceptions.NotFound()
Post = request.env['forum.post']
domain = [('forum_id', '=', forum.id), ('state', '=', 'flagged')]
if kwargs.get('spam_post'):
domain += [('name', 'ilike', kwargs.get('spam_post'))]
flagged_posts_ids = Post.search(domain, order='write_date DESC')
values = self._prepare_user_values(forum=forum)
values.update({
'posts_ids': flagged_posts_ids.sudo(),
'queue_type': 'flagged',
'flagged_queue_active': 1,
})
return request.render("website_forum.moderation_queue", values)
@http.route('/forum/<model("forum.forum"):forum>/offensive_posts', type='http', auth="user", website=True)
def offensive_posts(self, forum, **kwargs):
user = request.env.user
if user.karma < forum.karma_moderate:
raise werkzeug.exceptions.NotFound()
Post = request.env['forum.post']
domain = [('forum_id', '=', forum.id), ('state', '=', 'offensive'), ('active', '=', False)]
offensive_posts_ids = Post.search(domain, order='write_date DESC')
values = self._prepare_user_values(forum=forum)
values.update({
'posts_ids': offensive_posts_ids.sudo(),
'queue_type': 'offensive',
})
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):
if post.state == 'flagged':
url = f'/forum/{slug(forum)}/flagged_queue'
elif post.state == 'offensive':
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)
@http.route('/forum/<model("forum.forum"):forum>/post/<model("forum.post"):post>/refuse', type='http', auth="user", website=True)
def post_refuse(self, forum, post, **kwargs):
post.refuse()
return self.question_ask_for_close(forum, post)
@http.route('/forum/<model("forum.forum"):forum>/post/<model("forum.post"):post>/flag', type='json', auth="public", website=True)
def post_flag(self, forum, post, **kwargs):
if not request.session.uid:
return {'error': 'anonymous_user'}
return post.flag()[0]
@http.route('/forum/<model("forum.post"):post>/ask_for_mark_as_offensive', type='json', auth="user", website=True)
def post_json_ask_for_mark_as_offensive(self, post, **kwargs):
if not post.can_moderate:
raise AccessError(_('%d karma required to mark a post as offensive.', post.forum_id.karma_moderate))
values = self._prepare_mark_as_offensive_values(post, **kwargs)
return request.env['ir.ui.view']._render_template('website_forum.mark_as_offensive', values)
@http.route('/forum/<model("forum.forum"):forum>/post/<model("forum.post"):post>/ask_for_mark_as_offensive', type='http', auth="user", methods=['GET'], website=True)
def post_http_ask_for_mark_as_offensive(self, forum, post, **kwargs):
if not post.can_moderate:
raise AccessError(_('%d karma required to mark a post as offensive.', forum.karma_moderate))
values = self._prepare_mark_as_offensive_values(post, **kwargs)
return request.render("website_forum.close_post", values)
@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)))
if post.parent_id:
url = f'/forum/{slug(forum)}/{post.parent_id.id}/#answer-{post.id}'
else:
url = f'/forum/{slug(forum)}/{slug(post)}'
return request.redirect(url)
# User
# --------------------------------------------------
@http.route(['/forum/<model("forum.forum"):forum>/partner/<int:partner_id>'], type='http', auth="public", website=True)
def open_partner(self, forum, partner_id=0, **post):
if partner_id:
partner = request.env['res.partner'].sudo().search([('id', '=', partner_id)])
if partner and partner.user_ids:
return request.redirect(f'/forum/{slug(forum)}/user/{partner.user_ids[0].id}')
return request.redirect('/forum/' + slug(forum))
# Profile
# -----------------------------------
@http.route(['/forum/user/<int:user_id>'], type='http', auth="public", website=True)
def view_user_forum_profile(self, user_id, forum_id='', forum_origin='/forum', **post):
forum_origin_query = f'?forum_origin={forum_origin}&forum_id={forum_id}' if forum_id else ''
return request.redirect(f'/profile/user/{user_id}{forum_origin_query}')
def _prepare_user_profile_values(self, user, **post):
values = super(WebsiteForum, self)._prepare_user_profile_values(user, **post)
if not post.get('no_forum'):
if post.get('forum'):
forums = post['forum']
elif post.get('forum_id'):
forums = request.env['forum.forum'].browse(int(post['forum_id']))
values.update({
'edit_button_url_param': 'forum_id=%s' % str(post['forum_id']),
'forum_filtered': forums.name,
})
else:
forums = request.env['forum.forum'].search([])
values.update(self._prepare_user_values(forum=forums[0] if len(forums) == 1 else True, **post))
if forums:
values.update(self._prepare_open_forum_user(user, forums))
return values
def _prepare_open_forum_user(self, user, forums, **kwargs):
Post = request.env['forum.post']
Vote = request.env['forum.post.vote']
Activity = request.env['mail.message']
Followers = request.env['mail.followers']
Data = request.env["ir.model.data"]
# questions and answers by user
user_question_ids = Post.search([
('parent_id', '=', False),
('forum_id', 'in', forums.ids), ('create_uid', '=', user.id)],
order='create_date desc')
count_user_questions = len(user_question_ids)
min_karma_unlink = min(forums.mapped('karma_unlink_all'))
# limit length of visible posts by default for performance reasons, except for the high
# karma users (not many of them, and they need it to properly moderate the forum)
post_display_limit = None
if request.env.user.karma < min_karma_unlink:
post_display_limit = 20
user_questions = user_question_ids[:post_display_limit]
user_answer_ids = Post.search([
('parent_id', '!=', False),
('forum_id', 'in', forums.ids), ('create_uid', '=', user.id)],
order='create_date desc')
count_user_answers = len(user_answer_ids)
user_answers = user_answer_ids[:post_display_limit]
# showing questions which user following
post_ids = [follower.res_id for follower in Followers.sudo().search(
[('res_model', '=', 'forum.post'), ('partner_id', '=', user.partner_id.id)])]
followed = Post.search([('id', 'in', post_ids), ('forum_id', 'in', forums.ids), ('parent_id', '=', False)])
# showing Favourite questions of user.
favourite = Post.search(
[('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']
)
up_votes, down_votes = 0, 0
for vote, count in data:
if vote == '1':
up_votes = count
elif vote == '-1':
down_votes = count
# Votes which given by users on others questions and answers.
vote_ids = Vote.search([('user_id', '=', user.id), ('forum_id', 'in', forums.ids)])
# activity by user.
comment = Data._xmlid_lookup('mail.mt_comment')[1]
activities = Activity.search(
[('res_id', 'in', (user_question_ids + user_answer_ids).ids), ('model', '=', 'forum.post'),
('subtype_id', '!=', comment)],
order='date DESC', limit=100)
posts = {}
for act in activities:
posts[act.res_id] = True
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}
if user != request.env.user:
kwargs['users'] = True
values = {
'uid': request.env.user.id,
'user': user,
'main_object': user,
'searches': kwargs,
'questions': user_questions,
'count_questions': count_user_questions,
'answers': user_answers,
'count_answers': count_user_answers,
'followed': followed,
'favourite': favourite,
'up_votes': up_votes,
'down_votes': down_votes,
'activities': activities,
'posts': posts,
'vote_post': vote_ids,
'is_profile_page': True,
'badge_category': 'forum',
}
return values
# Messaging
# --------------------------------------------------
@http.route('/forum/<model("forum.forum"):forum>/post/<model("forum.post"):post>/comment/<model("mail.message"):comment>/convert_to_answer', type='http', auth="user", methods=['POST'], website=True)
def convert_comment_to_answer(self, forum, post, comment, **kwarg):
post = request.env['forum.post'].convert_comment_to_answer(comment.id)
if not post:
return request.redirect("/forum/%s" % slug(forum))
question = post.parent_id if post.parent_id else post
return request.redirect("/forum/%s/%s" % (slug(forum), slug(question)))
@http.route('/forum/<model("forum.forum"):forum>/post/<model("forum.post"):post>/convert_to_comment', type='http', auth="user", methods=['POST'], website=True)
def convert_answer_to_comment(self, forum, post, **kwarg):
question = post.parent_id
new_msg = post.convert_answer_to_comment()
if not new_msg:
return request.redirect("/forum/%s" % slug(forum))
return request.redirect("/forum/%s/%s" % (slug(forum), slug(question)))
@http.route('/forum/<model("forum.forum"):forum>/post/<model("forum.post"):post>/comment/<model("mail.message"):comment>/delete', type='json', auth="user", website=True)
def delete_comment(self, forum, post, comment, **kwarg):
if not request.session.uid:
return {'error': 'anonymous_user'}
return post.unlink_comment(comment.id)[0]

10
data/forum_forum_data.xml Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data noupdate="1">
<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>
</data></odoo>

View File

@ -0,0 +1,100 @@
<odoo>
<data>
<record id="default_faq" model="ir.ui.view">
<field name="name">Faq Accordion</field>
<field name="type">qweb</field>
<field name="key">website_forum.faq_accordion</field>
<field name="arch" type="xml">
<section class="s_faq_collapse mb-5">
<div class="container">
<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>
<li>how to configure or customize Odoo to specific business needs,</li>
<li>what's the best way to use Odoo for a specific business need,</li>
<li>how to develop modules for your own need,</li>
<li>specific questions about Odoo service offers, etc.</li>
</ul>
<p><b>Before you ask - please make sure to search for a similar question.</b> You can search questions by their title or tags. Its also OK to answer your own question.</p>
<p><b>Please avoid asking questions that are too subjective and argumentative</b> or not relevant to this community.</p>
</div>
</div>
</div>
<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>
<li>every answer is equally valid: “Whats your favorite ______?”</li>
<li>your answer is provided along with the question, and you expect more answers: “I use ______ for ______, what do you use?”</li>
<li>there is no actual problem to be solved: “Im curious if other people feel like I do.”</li>
<li>we are being asked an open-ended, hypothetical question: “What if ______ happened?”</li>
<li>it is a rant disguised as a question: “______ sucks, am I right?”</li>
</ul>
<p>If you fit in one of these example or if your motivation for asking the question is “I would like to participate in a discussion about ______”, then you should not be asking here but on our mailing lists. However, if your motivation is “I would like others to explain ______ to me”, then you are probably OK.</p>
<p>(The above section was adapted from Stackoverflows FAQ.)</p>
<p>More over:</p>
<ul>
<li><b>Answers should not add or expand questions</b>. Instead either edit the question or add a question comment.</li>
<li><b>Answers should not comment other answers</b>. Instead add a comment on the other answers.</li>
<li><b>Answers shouldn't just point to other Questions</b>. Instead add a question comment indication "Possible duplicate of...". However, it's ok to include links to other questions or answers providing relevant additional information.</li>
<li><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.</li>
</ul>
</div>
</div>
</div>
<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 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="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>
<a t-attf-href="/forum/#{slug(forum)}/faq/karma">Here a table with the privileges and the karma level</a>
</div>
</div>
</div>
</div>
</div>
</section>
</field>
</record>
</data>
</odoo>

72
data/forum_post_demo.xml Normal file
View File

@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="question_0" model="forum.post">
<field name="name">How to configure alerts for employee contract expiration</field>
<field name="forum_id" ref="website_forum.forum_help"/>
<field name="views">3</field>
<field name="create_uid" ref="base.user_admin"/>
<field name="write_uid" ref="base.user_admin"/>
<field name="tag_ids" eval="[(4,ref('website_forum.tags_0')), (4,ref('website_forum.tags_1'))]"/>
</record>
<record id="question_1" model="forum.post">
<field name="name">CMS replacement for ERP and eCommerce</field>
<field name="views">8</field>
<field name="create_uid" ref="base.user_admin"/>
<field name="write_uid" ref="base.user_admin"/>
<field name="forum_id" ref="website_forum.forum_help"/>
<field name="content"><![CDATA[<p>I use Wordpress as a CMS and eCommerce platform. The developing in Wordpress is quite easy and solid but it missing ERP feature (there is single plugin to integrate with Frontaccounting) so I wonder:
Can I use Odoo as a replacement CMS of Wordpress + eCommerce plugin?
In simple words does Odoo became CMS+ERP platform?</p>]]></field>
<field name="tag_ids" eval="[(4,ref('website_forum.tags_2'))]"/>
</record>
<!-- Answer -->
<record id="answer_0" model="forum.post">
<field name="create_uid" ref="base.user_admin"/>
<field name="forum_id" ref="website_forum.forum_help"/>
<field name="name">Re: How to configure alerts for employee contract expiration</field>
<field name="content"><![CDATA[<p>Just for posterity so other can see. Here are the steps to set automatic alerts on any contract.. i.e. HR Employee, or Fleet for example. I will use fleet as an example.</p>
<ul>
<li>Step 1. As a user who has access rights to Technical Features, go to Settings --> Automation Rules. Create A new Automation Rule. For the Related Document Model choose.. Contract information on a vehicle (you can also type in the actual model name.. fleet.vehicle.log.contract ) . Set the trigger date to ... Contract Expiration Date. The Next Field (Delay After Trigger Date) is a bit ridiculous. Who wants to be reminded of a contract expiration AFTER the fact? The field should say Days Before Date to Fire Rule and the number should be converted to a negative. IMHO. Any way... to get a workable solution you must enter in the number in the negative. So for instance like me if you want to be warned 35 days BEFORE the expiration... put in Delay After Trigger Date.. the number -35 But the sake of testing, right now just put in -1 for 1 day before. Save the Rule.
<li>Step 2. Go to Server Actions and create new Action. Call it Fleet Contract Expiration Warning. The Object will be the same as above .. Contract information on a vehicle. The Action Type is Email. For email address I just put my email. Under subject put in... [[object.name]]. This will tell you the name of the car. Message you can put any text message you like. Now save the Server Action.</li>
<li>Step 3. Now go back to the Automation Rule you created and go to the Rule tab next to the conditions tab. Click Add and add the server action you created . In this case Fleet Contract Expiration Warning. Then Save.</li>
<li>Step 4. To test, set a contract to expire tomorrow under one of your fleets vehicles. Then Save it.</li>
<li>Step 5. Go to Scheduled Actions.. Set interval number to 1. Interval Unit to Minutes. Then Set the Next Execution date to 2 minutes from now. If your SMTP is configured correctly you will start to get a mail every minute with the reminder.</li></ul>]]></field>
<field name="parent_id" ref="question_0" />
</record>
<record id="answer_1" model="forum.post">
<field name="create_uid" ref="base.user_admin"/>
<field name="forum_id" ref="website_forum.forum_help"/>
<field name="name">Re: CMS replacement for ERP and eCommerce</field>
<field name="content"><![CDATA[
<p>Odoo provides a web module and an e-commerce module: www.odoo.com/app/website
The CMS editor in Odoo web is nice but I prefer Drupal for customization and there is a Drupal module for Odoo. I think WP is better than Odoo web too.
</p>]]></field>
<field name="parent_id" ref="question_1"/>
</record>
<!-- Post Vote -->
<record id="post_vote_0" model="forum.post.vote">
<field name="post_id" ref="question_0"/>
<field name="create_uid" ref="base.user_admin"/>
<field name="user_id" ref="base.user_demo"/>
<field name="vote">1</field>
</record>
<record id="post_vote_1" model="forum.post.vote">
<field name="post_id" ref="answer_0"/>
<field name="create_uid" ref="base.user_admin"/>
<field name="user_id" ref="base.user_demo"/>
<field name="vote">1</field>
</record>
<!-- Run Scheduler -->
<function model="gamification.challenge" name="_cron_update">
<value eval="False"/>
<value eval="False"/>
</function>
</data>
</odoo>

View File

@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Reasons for closing Post -->
<record id="reason_1" model="forum.post.reason">
<field name="name">Duplicate post</field>
<field name="reason_type">basic</field>
</record>
<record id="reason_2" model="forum.post.reason">
<field name="name">Off-topic or not relevant</field>
<field name="reason_type">basic</field>
</record>
<record id="reason_3" model="forum.post.reason">
<field name="name">Too subjective and argumentative</field>
<field name="reason_type">basic</field>
</record>
<record id="reason_4" model="forum.post.reason">
<field name="name">Not a real post</field>
<field name="reason_type">basic</field>
</record>
<record id="reason_6" model="forum.post.reason">
<field name="name">Not relevant or out dated</field>
<field name="reason_type">basic</field>
</record>
<record id="reason_7" model="forum.post.reason">
<field name="name">Contains offensive or malicious remarks</field>
<field name="reason_type">basic</field>
</record>
<record id="reason_8" model="forum.post.reason">
<field name="name">Spam or advertising</field>
<field name="reason_type">basic</field>
</record>
<record id="reason_9" model="forum.post.reason">
<field name="name">Too localized</field>
<field name="reason_type">basic</field>
</record>
<record id="reason_11" model="forum.post.reason">
<field name="name">Insulting and offensive language</field>
<field name="reason_type">offensive</field>
</record>
<record id="reason_12" model="forum.post.reason">
<field name="name">Violent language</field>
<field name="reason_type">offensive</field>
</record>
<record id="reason_13" model="forum.post.reason">
<field name="name">Inappropriate and unacceptable statements</field>
<field name="reason_type">offensive</field>
</record>
<record id="reason_14" model="forum.post.reason">
<field name="name">Threatening language</field>
<field name="reason_type">offensive</field>
</record>
<record id="reason_15" model="forum.post.reason">
<field name="name">Racist and hate speech</field>
<field name="reason_type">offensive</field>
</record>
</data>
</odoo>

20
data/forum_tag_demo.xml Normal file
View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data>
<!-- Tag -->
<record id="tags_0" model="forum.tag">
<field name="name">Contract</field>
<field name="forum_id" ref="website_forum.forum_help"/>
</record>
<record id="tags_1" model="forum.tag">
<field name="name">Action</field>
<field name="forum_id" ref="website_forum.forum_help"/>
</record>
<record id="tags_2" model="forum.tag">
<field name="name">ecommerce</field>
<field name="forum_id" ref="website_forum.forum_help"/>
</record>
<record id="tags_3" model="forum.tag">
<field name="name">Development</field>
<field name="forum_id" ref="website_forum.forum_help"/>
</record>
</data></odoo>

View File

@ -0,0 +1,256 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- QUALITY (VOTES) -->
<!-- Teacher: at least 3 upvotes -->
<record id="badge_a_1" model="gamification.badge">
<field name="name">Teacher</field>
<field name="description">Received at least 3 upvote for an answer for the first time</field>
<field name="level">bronze</field>
<field name="rule_auth">nobody</field>
</record>
<record model="gamification.goal.definition" id="definition_teacher">
<field name="name">Teacher</field>
<field name="description">Received at least 3 upvote for an answer for the first time</field>
<field name="computation_mode">count</field>
<field name="display_mode">boolean</field>
<field name="model_id" ref="website_forum.model_forum_post"/>
<field name="condition">higher</field>
<field name="domain">[('parent_id', '!=', False), ('vote_count', '>=', 3)]</field>
<field name="batch_mode">True</field>
<field name="batch_distinctive_field" ref="website_forum.field_forum_post__create_uid"/>
<field name="batch_user_expression">user.id</field>
</record>
<record model="gamification.challenge" id="challenge_teacher">
<field name="name">Teacher</field>
<field name="period">once</field>
<field name="visibility_mode">personal</field>
<field name="report_message_frequency">never</field>
<field name="reward_id" ref="badge_a_1"/>
<field name="reward_realtime">True</field>
<field name="user_domain">[('karma', '>', 0)]</field>
<field name="state">inprogress</field>
<field name="challenge_category">forum</field>
</record>
<record model="gamification.challenge.line" id="line_teacher">
<field name="definition_id" ref="definition_teacher"/>
<field name="challenge_id" ref="challenge_teacher"/>
<field name="target_goal">1</field>
</record>
<!-- Nice: at least 4 upvotes -->
<record id="badge_a_2" model="gamification.badge">
<field name="name">Nice Answer</field>
<field name="description">Answer voted up 4 times</field>
<field name="level">bronze</field>
<field name="rule_auth">nobody</field>
</record>
<record model="gamification.goal.definition" id="definition_nice_answer">
<field name="name">Nice Answer (4)</field>
<field name="description">Answer voted up 4 times</field>
<field name="computation_mode">count</field>
<field name="display_mode">boolean</field>
<field name="model_id" ref="website_forum.model_forum_post"/>
<field name="condition">higher</field>
<field name="domain">[('parent_id', '!=', False), ('vote_count', '>=', 4)]</field>
<field name="batch_mode">True</field>
<field name="batch_distinctive_field" ref="website_forum.field_forum_post__create_uid"/>
<field name="batch_user_expression">user.id</field>
</record>
<record model="gamification.challenge" id="challenge_nice_answer">
<field name="name">Nice Answer</field>
<field name="period">once</field>
<field name="visibility_mode">personal</field>
<field name="report_message_frequency">never</field>
<field name="reward_id" ref="badge_a_2"/>
<field name="reward_realtime">True</field>
<field name="user_domain">[('karma', '>', 0)]</field>
<field name="state">inprogress</field>
<field name="challenge_category">forum</field>
</record>
<record model="gamification.challenge.line" id="line_nice_answer">
<field name="definition_id" ref="definition_nice_answer"/>
<field name="challenge_id" ref="challenge_nice_answer"/>
<field name="target_goal">1</field>
</record>
<!-- Good: at least 6 upvotes -->
<record id="badge_a_3" model="gamification.badge">
<field name="name">Good Answer</field>
<field name="description">Answer voted up 6 times</field>
<field name="level">silver</field>
<field name="rule_auth">nobody</field>
</record>
<record model="gamification.goal.definition" id="definition_good_answer">
<field name="name">Good Answer (6)</field>
<field name="description">Answer voted up 6 times</field>
<field name="computation_mode">count</field>
<field name="display_mode">boolean</field>
<field name="model_id" ref="website_forum.model_forum_post"/>
<field name="condition">higher</field>
<field name="domain">[('parent_id', '!=', False), ('vote_count', '>=', 6)]</field>
<field name="batch_mode">True</field>
<field name="batch_distinctive_field" ref="website_forum.field_forum_post__create_uid"/>
<field name="batch_user_expression">user.id</field>
</record>
<record model="gamification.challenge" id="challenge_good_answer">
<field name="name">Good Answer</field>
<field name="period">once</field>
<field name="visibility_mode">personal</field>
<field name="report_message_frequency">never</field>
<field name="reward_id" ref="badge_a_3"/>
<field name="reward_realtime">True</field>
<field name="user_domain">[('karma', '>', 0)]</field>
<field name="state">inprogress</field>
<field name="challenge_category">forum</field>
</record>
<record model="gamification.challenge.line" id="line_good_answer">
<field name="definition_id" ref="definition_good_answer"/>
<field name="challenge_id" ref="challenge_good_answer"/>
<field name="target_goal">1</field>
</record>
<!-- Great: at least 15 upvotes -->
<record id="badge_a_4" model="gamification.badge">
<field name="name">Great Answer</field>
<field name="description">Answer voted up 15 times</field>
<field name="level">gold</field>
<field name="rule_auth">nobody</field>
</record>
<record model="gamification.goal.definition" id="definition_great_answer">
<field name="name">Great Answer (15)</field>
<field name="description">Answer voted up 15 times</field>
<field name="computation_mode">count</field>
<field name="display_mode">boolean</field>
<field name="model_id" ref="website_forum.model_forum_post"/>
<field name="condition">higher</field>
<field name="domain">[('parent_id', '!=', False), ('vote_count', '>=', 15)]</field>
<field name="batch_mode">True</field>
<field name="batch_distinctive_field" ref="website_forum.field_forum_post__create_uid"/>
<field name="batch_user_expression">user.id</field>
</record>
<record model="gamification.challenge" id="challenge_great_answer">
<field name="name">Great Answer</field>
<field name="period">once</field>
<field name="visibility_mode">personal</field>
<field name="report_message_frequency">never</field>
<field name="reward_id" ref="badge_a_4"/>
<field name="reward_realtime">True</field>
<field name="user_domain">[('karma', '>', 0)]</field>
<field name="state">inprogress</field>
<field name="challenge_category">forum</field>
</record>
<record model="gamification.challenge.line" id="line_great_answer">
<field name="definition_id" ref="definition_great_answer"/>
<field name="challenge_id" ref="challenge_great_answer"/>
<field name="target_goal">1</field>
</record>
<!-- ACCEPTANCE -->
<!-- Enlightened: at least 3 upvotes for an accepted answer -->
<record id="badge_a_5" model="gamification.badge">
<field name="name">Enlightened</field>
<field name="description">Answer was accepted with 3 or more votes</field>
<field name="level">silver</field>
<field name="rule_auth">nobody</field>
</record>
<record model="gamification.goal.definition" id="definition_enlightened">
<field name="name">Enlightened</field>
<field name="description">Answer was accepted with 3 or more votes</field>
<field name="computation_mode">count</field>
<field name="display_mode">boolean</field>
<field name="model_id" ref="website_forum.model_forum_post"/>
<field name="condition">higher</field>
<field name="domain">[('parent_id', '!=', False), ('vote_count', '>=', 3), ('is_correct', '=', True)]</field>
<field name="batch_mode">True</field>
<field name="batch_distinctive_field" ref="website_forum.field_forum_post__create_uid"/>
<field name="batch_user_expression">user.id</field>
</record>
<record model="gamification.challenge" id="challenge_enlightened">
<field name="name">Enlightened</field>
<field name="period">once</field>
<field name="visibility_mode">personal</field>
<field name="report_message_frequency">never</field>
<field name="reward_id" ref="badge_a_5"/>
<field name="reward_realtime">True</field>
<field name="user_domain">[('karma', '>', 0)]</field>
<field name="state">inprogress</field>
<field name="challenge_category">forum</field>
</record>
<record model="gamification.challenge.line" id="line_enlightened">
<field name="definition_id" ref="definition_enlightened"/>
<field name="challenge_id" ref="challenge_enlightened"/>
<field name="target_goal">1</field>
</record>
<!-- Guru: at least 15 upvotes for an accepted answer -->
<record id="badge_a_6" model="gamification.badge">
<field name="name">Guru</field>
<field name="description">Answer accepted with 15 or more votes</field>
<field name="level">silver</field>
<field name="rule_auth">nobody</field>
</record>
<record model="gamification.goal.definition" id="definition_guru">
<field name="name">Guru (15)</field>
<field name="description">Answer accepted with 15 or more votes</field>
<field name="computation_mode">count</field>
<field name="display_mode">boolean</field>
<field name="model_id" ref="website_forum.model_forum_post"/>
<field name="condition">higher</field>
<field name="domain">[('parent_id', '!=', False), ('vote_count', '>=', 15), ('is_correct', '=', True)]</field>
<field name="batch_mode">True</field>
<field name="batch_distinctive_field" ref="website_forum.field_forum_post__create_uid"/>
<field name="batch_user_expression">user.id</field>
</record>
<record model="gamification.challenge" id="challenge_guru">
<field name="name">Guru</field>
<field name="period">once</field>
<field name="visibility_mode">personal</field>
<field name="report_message_frequency">never</field>
<field name="reward_id" ref="badge_a_6"/>
<field name="reward_realtime">True</field>
<field name="user_domain">[('karma', '>', 0)]</field>
<field name="state">inprogress</field>
<field name="challenge_category">forum</field>
</record>
<record model="gamification.challenge.line" id="line_guru">
<field name="definition_id" ref="definition_guru"/>
<field name="target_goal">1</field>
<field name="challenge_id" ref="challenge_guru"/>
</record>
<!-- Sealf Leaner: own question, 3+ upvotes -->
<record id="badge_a_8" model="gamification.badge">
<field name="name">Self-Learner</field>
<field name="description">Answered own question with at least 4 up votes</field>
<field name="level">gold</field>
<field name="rule_auth">nobody</field>
</record>
<record model="gamification.goal.definition" id="definition_self_learner">
<field name="name">Self-Learner</field>
<field name="description">Answer own question with at least 4 up votes</field>
<field name="computation_mode">count</field>
<field name="display_mode">boolean</field>
<field name="model_id" ref="website_forum.model_forum_post"/>
<field name="condition">higher</field>
<field name="domain">[('self_reply', '=', True), ('vote_count', '>=', 4)]</field>
<field name="batch_mode">True</field>
<field name="batch_distinctive_field" ref="website_forum.field_forum_post__create_uid"/>
<field name="batch_user_expression">user.id</field>
</record>
<record model="gamification.challenge" id="challenge_self_learner">
<field name="name">Self-Learner</field>
<field name="period">once</field>
<field name="visibility_mode">personal</field>
<field name="report_message_frequency">never</field>
<field name="reward_id" ref="badge_a_8"/>
<field name="reward_realtime">True</field>
<field name="user_domain">[('karma', '>', 0)]</field>
<field name="state">inprogress</field>
<field name="challenge_category">forum</field>
</record>
<record model="gamification.challenge.line" id="line_self_learner">
<field name="definition_id" ref="definition_self_learner"/>
<field name="target_goal">1</field>
<field name="challenge_id" ref="challenge_self_learner"/>
</record>
</data>
</odoo>

View File

@ -0,0 +1,193 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Cleanup: answer or question edition -->
<!-- Not rollback feature in forum -->
<!-- <record id="badge_3" model="gamification.badge">
<field name="name">Cleanup</field>
<field name="description">First rollback</field>
<field name="level">gold</field>
</record> -->
<!-- Critic: downvote based -->
<record id="badge_5" model="gamification.badge">
<field name="name">Critic</field>
<field name="description">First downvote</field>
<field name="level">bronze</field>
<field name="rule_auth">nobody</field>
</record>
<record model="gamification.goal.definition" id="definition_critic">
<field name="name">Critic</field>
<field name="description">First downvote</field>
<field name="computation_mode">count</field>
<field name="display_mode">boolean</field>
<field name="model_id" ref="website_forum.model_forum_post_vote"/>
<field name="condition">higher</field>
<field name="domain">[('vote', '=', '-1')]</field>
<field name="batch_mode">True</field>
<field name="batch_distinctive_field" ref="website_forum.field_forum_post_vote__user_id"/>
<field name="batch_user_expression">user.id</field>
</record>
<record model="gamification.challenge" id="challenge_critic">
<field name="name">Critic</field>
<field name="period">once</field>
<field name="visibility_mode">personal</field>
<field name="report_message_frequency">never</field>
<field name="reward_id" ref="badge_5"/>
<field name="reward_realtime">True</field>
<field name="user_domain">[('karma', '>', 0)]</field>
<field name="state">inprogress</field>
<field name="challenge_category">forum</field>
</record>
<record model="gamification.challenge.line" id="line_critic">
<field name="definition_id" ref="definition_critic"/>
<field name="challenge_id" ref="challenge_critic"/>
<field name="target_goal">1</field>
</record>
<!-- Disciplined: delete own post with >=3 upvotes -->
<record id="badge_6" model="gamification.badge">
<field name="name">Disciplined</field>
<field name="description">Deleted own post with 3 or more upvotes</field>
<field name="level">bronze</field>
<field name="rule_auth">nobody</field>
</record>
<record model="gamification.goal.definition" id="definition_disciplined">
<field name="name">Disciplined</field>
<field name="description">Delete own post with 3 or more upvotes</field>
<field name="computation_mode">count</field>
<field name="display_mode">boolean</field>
<field name="model_id" ref="website_forum.model_forum_post"/>
<field name="condition">higher</field>
<field name="domain">[('vote_count', '>=', 3), ('active', '=', False)]</field>
<field name="batch_mode">True</field>
<field name="batch_distinctive_field" ref="website_forum.field_forum_post__create_uid"/>
<field name="batch_user_expression">user.id</field>
</record>
<record model="gamification.challenge" id="challenge_disciplined">
<field name="name">Disciplined</field>
<field name="period">once</field>
<field name="visibility_mode">personal</field>
<field name="report_message_frequency">never</field>
<field name="reward_id" ref="badge_6"/>
<field name="reward_realtime">True</field>
<field name="user_domain">[('karma', '>', 0)]</field>
<field name="state">inprogress</field>
<field name="challenge_category">forum</field>
</record>
<record model="gamification.challenge.line" id="line_disciplined">
<field name="definition_id" ref="definition_disciplined"/>
<field name="challenge_id" ref="challenge_disciplined"/>
<field name="target_goal">1</field>
</record>
<!-- Editor: first edit -->
<record id="badge_7" model="gamification.badge">
<field name="name">Editor</field>
<field name="description">First edit</field>
<field name="level">gold</field>
<field name="rule_auth">nobody</field>
</record>
<record model="gamification.goal.definition" id="definition_editor">
<field name="name">Editor</field>
<field name="description">First edit of answer or question</field>
<field name="computation_mode">count</field>
<field name="display_mode">boolean</field>
<field name="model_id" ref="mail.model_mail_message"/>
<field name="condition">higher</field>
<field name="domain" eval="[('model', '=', 'forum.post'), ('subtype_id', 'in', [ref('website_forum.mt_answer_edit'), ref('website_forum.mt_question_edit')])]"/>
<field name="batch_mode">True</field>
<field name="batch_distinctive_field" ref="mail.field_mail_message__author_id"/>
<field name="batch_user_expression">user.partner_id.id</field>
</record>
<record model="gamification.challenge" id="challenge_editor">
<field name="name">Editor</field>
<field name="period">once</field>
<field name="visibility_mode">personal</field>
<field name="report_message_frequency">never</field>
<field name="reward_id" ref="badge_7"/>
<field name="reward_realtime">True</field>
<field name="user_domain">[('karma', '>', 0)]</field>
<field name="state">inprogress</field>
<field name="challenge_category">forum</field>
</record>
<record model="gamification.challenge.line" id="line_editor">
<field name="definition_id" ref="definition_editor"/>
<field name="challenge_id" ref="challenge_editor"/>
<field name="target_goal">1</field>
</record>
<record id="badge_31" model="gamification.badge">
<field name="name">Supporter</field>
<field name="description">First upvote</field>
<field name="level">gold</field>
<field name="rule_auth">nobody</field>
</record>
<record model="gamification.goal.definition" id="definition_supporter">
<field name="name">Supporter</field>
<field name="description">First upvote</field>
<field name="computation_mode">count</field>
<field name="display_mode">boolean</field>
<field name="model_id" ref="website_forum.model_forum_post_vote"/>
<field name="condition">higher</field>
<field name="domain">[('vote', '=', '1')]</field>
<field name="batch_mode">True</field>
<field name="batch_distinctive_field" ref="website_forum.field_forum_post_vote__user_id"/>
<field name="batch_user_expression">user.id</field>
</record>
<record model="gamification.challenge" id="challenge_supporter">
<field name="name">Supporter</field>
<field name="period">once</field>
<field name="visibility_mode">personal</field>
<field name="report_message_frequency">never</field>
<field name="reward_id" ref="badge_31"/>
<field name="reward_realtime">True</field>
<field name="user_domain">[('karma', '>', 0)]</field>
<field name="state">inprogress</field>
<field name="challenge_category">forum</field>
</record>
<record model="gamification.challenge.line" id="line_supporter">
<field name="definition_id" ref="definition_supporter"/>
<field name="target_goal">1</field>
<field name="challenge_id" ref="challenge_supporter"/>
</record>
<record id="badge_23" model="gamification.badge">
<field name="name">Peer Pressure</field>
<field name="description">Deleted own post with 3 or more downvotes</field>
<field name="level">gold</field>
<field name="rule_auth">nobody</field>
</record>
<record model="gamification.goal.definition" id="definition_peer_pressure">
<field name="name">Peer Pressure</field>
<field name="description">Delete own post with 3 or more down votes</field>
<field name="computation_mode">count</field>
<field name="display_mode">boolean</field>
<field name="model_id" ref="website_forum.model_forum_post"/>
<field name="condition">higher</field>
<field name="domain">[('vote_count', '&lt;=', -3), ('active', '=', False)]</field>
<field name="batch_mode">True</field>
<field name="batch_distinctive_field" ref="website_forum.field_forum_post__create_uid"/>
<field name="batch_user_expression">user.id</field>
</record>
<record model="gamification.challenge" id="challenge_peer_pressure">
<field name="name">Peer Pressure</field>
<field name="period">once</field>
<field name="visibility_mode">personal</field>
<field name="report_message_frequency">never</field>
<field name="reward_id" ref="badge_23"/>
<field name="reward_realtime">True</field>
<field name="user_domain">[('karma', '>', 0)]</field>
<field name="state">inprogress</field>
<field name="challenge_category">forum</field>
</record>
<record model="gamification.challenge.line" id="line_peer_pressure">
<field name="definition_id" ref="definition_peer_pressure"/>
<field name="target_goal">1</field>
<field name="challenge_id" ref="challenge_peer_pressure"/>
</record>
</data>
</odoo>

View File

@ -0,0 +1,176 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Biography: complet your profile -->
<record id="badge_p_1" model="gamification.badge">
<field name="name">Autobiographer</field>
<field name="description">Completed own biography</field>
<field name="level">bronze</field>
<field name="rule_auth">nobody</field>
</record>
<record model="gamification.goal.definition" id="definition_configure_profile">
<field name="name">Completed own biography</field>
<field name="description">Write some information about yourself</field>
<field name="computation_mode">count</field>
<field name="display_mode">boolean</field>
<field name="model_id" ref="base.model_res_users"/>
<field name="condition">higher</field>
<field name="domain">[
('partner_id.country_id', '!=', False),
('partner_id.city', '!=', False),
('partner_id.email', '!=', False)
]</field>
<field name="batch_mode">True</field>
<field name="batch_distinctive_field" ref="base.field_res_users__id"/>
<field name="batch_user_expression">user.id</field>
</record>
<record model="gamification.challenge" id="challenge_configure_profile">
<field name="name">Complete own biography</field>
<field name="period">once</field>
<field name="visibility_mode">personal</field>
<field name="report_message_frequency">never</field>
<field name="reward_id" ref="badge_p_1"/>
<field name="reward_realtime">True</field>
<field name="user_domain">[('karma', '>', 0)]</field>
<field name="state">inprogress</field>
<field name="challenge_category">forum</field>
</record>
<record model="gamification.challenge.line" id="line_configure_profile">
<field name="definition_id" ref="definition_configure_profile"/>
<field name="challenge_id" ref="challenge_configure_profile"/>
<field name="target_goal">1</field>
</record>
<!-- Commentator: at least 10 comments posted on posts -->
<record id="badge_p_2" model="gamification.badge">
<field name="name">Commentator</field>
<field name="description">Posted 10 comments</field>
<field name="level">bronze</field>
<field name="rule_auth">nobody</field>
</record>
<record model="gamification.goal.definition" id="definition_commentator">
<field name="name">Commentator</field>
<field name="description">Comment an answer or a question</field>
<field name="computation_mode">count</field>
<field name="display_mode">boolean</field>
<field name="model_id" ref="mail.model_mail_message"/>
<field name="condition">higher</field>
<field name="domain" eval="[('message_type', '=', 'comment'), ('subtype_id', '=', ref('mail.mt_comment')), ('model', '=', 'forum.post')]"/>
<field name="batch_mode">True</field>
<field name="batch_distinctive_field" ref="mail.field_mail_message__author_id"/>
<field name="batch_user_expression">user.partner_id.id</field>
</record>
<record model="gamification.challenge" id="challenge_commentator">
<field name="name">Commentator</field>
<field name="period">once</field>
<field name="visibility_mode">personal</field>
<field name="report_message_frequency">never</field>
<field name="reward_id" ref="badge_p_2"/>
<field name="reward_realtime">True</field>
<field name="user_domain">[('karma', '>', 0)]</field>
<field name="state">inprogress</field>
<field name="challenge_category">forum</field>
</record>
<record model="gamification.challenge.line" id="line_commentator">
<field name="definition_id" ref="definition_commentator"/>
<field name="challenge_id" ref="challenge_commentator"/>
<field name="target_goal">10</field>
</record>
<!-- Pundit: 10 answers with at least score of 10 -->
<record id="badge_25" model="gamification.badge">
<field name="name">Pundit</field>
<field name="description">Left 10 answers with score of 10 or more</field>
<field name="level">silver</field>
<field name="rule_auth">nobody</field>
</record>
<record model="gamification.goal.definition" id="definition_pundit">
<field name="name">Pundit</field>
<field name="description">Post 10 answers with score of 10 or more</field>
<field name="display_mode">boolean</field>
<field name="condition">higher</field>
<field name="model_id" ref="website_forum.model_forum_post"/>
<field name="domain" eval="[('parent', '!=', False), ('vote_count' '>=', 10)]"/>
<field name="batch_mode">True</field>
<field name="batch_distinctive_field" ref="website_forum.field_forum_post__create_uid"/>
<field name="batch_user_expression">user.id</field>
</record>
<record model="gamification.challenge" id="challenge_pundit">
<field name="name">Pundit</field>
<field name="period">once</field>
<field name="visibility_mode">personal</field>
<field name="report_message_frequency">never</field>
<field name="reward_id" ref="badge_25"/>
<field name="reward_realtime">True</field>
<field name="user_domain">[('karma', '>', 0)]</field>
<field name="state">inprogress</field>
<field name="challenge_category">forum</field>
</record>
<record model="gamification.challenge.line" id="line_pundit">
<field name="definition_id" ref="definition_pundit"/>
<field name="target_goal">10</field>
<field name="challenge_id" ref="challenge_pundit"/>
</record>
<!-- Chief Commentator: 100 comments -->
<record id="badge_p_4" model="gamification.badge">
<field name="name">Chief Commentator</field>
<field name="description">Posted 100 comments</field>
<field name="level">silver</field>
<field name="rule_auth">nobody</field>
</record>
<record model="gamification.challenge" id="challenge_chief_commentator">
<field name="name">Chief Commentator</field>
<field name="period">once</field>
<field name="visibility_mode">personal</field>
<field name="report_message_frequency">never</field>
<field name="reward_id" ref="badge_p_4"/>
<field name="reward_realtime">True</field>
<field name="user_domain">[('karma', '>', 0)]</field>
<field name="state">inprogress</field>
<field name="challenge_category">forum</field>
</record>
<record model="gamification.challenge.line" id="line_chief_commentator">
<field name="definition_id" ref="definition_commentator"/>
<field name="challenge_id" ref="challenge_chief_commentator"/>
<field name="target_goal">100</field>
</record>
<record id="badge_32" model="gamification.badge">
<field name="name">Taxonomist</field>
<field name="description">Created a tag used by 15 questions</field>
<field name="level">silver</field>
<field name="rule_auth">nobody</field>
</record>
<record model="gamification.goal.definition" id="definition_taxonomist">
<field name="name">Taxonomist</field>
<field name="description">Create a tag which can used in minimum 15 questions</field>
<field name="computation_mode">count</field>
<field name="display_mode">boolean</field>
<field name="model_id" ref="website_forum.model_forum_tag"/>
<field name="condition">higher</field>
<field name="domain">[('posts_count', '>=', 15)]</field>
<field name="batch_mode">True</field>
<field name="batch_distinctive_field" ref="website_forum.field_forum_tag__create_uid"/>
<field name="batch_user_expression">user.id</field>
</record>
<record model="gamification.challenge" id="challenge_taxonomist">
<field name="name">Taxonomist</field>
<field name="period">once</field>
<field name="visibility_mode">personal</field>
<field name="report_message_frequency">never</field>
<field name="reward_id" ref="badge_32"/>
<field name="reward_realtime">True</field>
<field name="user_domain">[('karma', '>', 0)]</field>
<field name="state">inprogress</field>
<field name="challenge_category">forum</field>
</record>
<record model="gamification.challenge.line" id="line_taxonomist">
<field name="definition_id" ref="definition_taxonomist"/>
<field name="challenge_id" ref="challenge_taxonomist"/>
<field name="target_goal">1</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,399 @@
<!-- <?xml version="1.0" encoding="utf-8"?> -->
<odoo>
<data noupdate="1">
<!-- POPULARITY (VIEWS) -->
<!-- Popular: 150 views -->
<record id="badge_q_1" model="gamification.badge">
<field name="name">Popular Question</field>
<field name="description">Asked a question with at least 150 views</field>
<field name="level">bronze</field>
<field name="rule_auth">nobody</field>
</record>
<record model="gamification.goal.definition" id="definition_popular_question">
<field name="name">Popular Question (150)</field>
<field name="description">Asked a question with at least 150 views</field>
<field name="computation_mode">count</field>
<field name="display_mode">boolean</field>
<field name="model_id" ref="website_forum.model_forum_post"/>
<field name="domain">[('parent_id', '=', False), ('views', '>=', 150)]</field>
<field name="condition">higher</field>
<field name="batch_mode">True</field>
<field name="batch_distinctive_field" ref="website_forum.field_forum_post__create_uid"/>
<field name="batch_user_expression">user.id</field>
</record>
<record model="gamification.challenge" id="challenge_popular_question">
<field name="name">Popular Question</field>
<field name="period">once</field>
<field name="visibility_mode">personal</field>
<field name="report_message_frequency">never</field>
<field name="reward_id" ref="badge_q_1"/>
<field name="reward_realtime">True</field>
<field name="user_domain">[('karma', '>', 0)]</field>
<field name="state">inprogress</field>
<field name="challenge_category">forum</field>
</record>
<record model="gamification.challenge.line" id="line_popular_question">
<field name="definition_id" ref="definition_popular_question"/>
<field name="challenge_id" ref="challenge_popular_question"/>
<field name="target_goal">1</field>
</record>
<!-- Notable: 250 views -->
<record id="badge_q_2" model="gamification.badge">
<field name="name">Notable Question</field>
<field name="description">Asked a question with at least 250 views</field>
<field name="level">silver</field>
<field name="rule_auth">nobody</field>
</record>
<record model="gamification.goal.definition" id="definition_notable_question">
<field name="name">Popular Question (250)</field>
<field name="description">Asked a question with at least 250 views</field>
<field name="computation_mode">count</field>
<field name="display_mode">boolean</field>
<field name="model_id" ref="website_forum.model_forum_post"/>
<field name="domain">[('parent_id', '=', False), ('views', '>=', 250)]</field>
<field name="condition">higher</field>
<field name="batch_mode">True</field>
<field name="batch_distinctive_field" ref="website_forum.field_forum_post__create_uid"/>
<field name="batch_user_expression">user.id</field>
</record>
<record model="gamification.challenge" id="challenge_notable_question">
<field name="name">Notable Question</field>
<field name="period">once</field>
<field name="visibility_mode">personal</field>
<field name="report_message_frequency">never</field>
<field name="reward_id" ref="badge_q_2"/>
<field name="reward_realtime">True</field>
<field name="user_domain">[('karma', '>', 0)]</field>
<field name="state">inprogress</field>
<field name="challenge_category">forum</field>
</record>
<record model="gamification.challenge.line" id="line_notable_question">
<field name="definition_id" ref="definition_notable_question"/>
<field name="challenge_id" ref="challenge_notable_question"/>
<field name="target_goal">1</field>
</record>
<!-- Famous: 500 views -->
<record id="badge_q_3" model="gamification.badge">
<field name="name">Famous Question</field>
<field name="description">Asked a question with at least 500 views</field>
<field name="level">gold</field>
<field name="rule_auth">nobody</field>
</record>
<record model="gamification.goal.definition" id="definition_famous_question">
<field name="name">Popular Question (500)</field>
<field name="description">Asked a question with at least 500 views</field>
<field name="computation_mode">count</field>
<field name="display_mode">boolean</field>
<field name="model_id" ref="website_forum.model_forum_post"/>
<field name="domain">[('parent_id', '=', False), ('views', '>=', 500)]</field>
<field name="condition">higher</field>
<field name="batch_mode">True</field>
<field name="batch_distinctive_field" ref="website_forum.field_forum_post__create_uid"/>
<field name="batch_user_expression">user.id</field>
</record>
<record model="gamification.challenge" id="challenge_famous_question">
<field name="name">Famous Question</field>
<field name="period">once</field>
<field name="visibility_mode">personal</field>
<field name="report_message_frequency">never</field>
<field name="reward_id" ref="badge_q_3"/>
<field name="reward_realtime">True</field>
<field name="user_domain">[('karma', '>', 0)]</field>
<field name="state">inprogress</field>
<field name="challenge_category">forum</field>
</record>
<record model="gamification.challenge.line" id="line_famous_question">
<field name="definition_id" ref="definition_famous_question"/>
<field name="challenge_id" ref="challenge_famous_question"/>
<field name="target_goal">1</field>
</record>
<!-- FAVORITE -->
<!-- Credible: at least 1 user have it in favorite -->
<record id="badge_q_4" model="gamification.badge">
<field name="name">Credible Question</field>
<field name="description">Question set as favorite by 1 user</field>
<field name="level">bronze</field>
<field name="rule_auth">nobody</field>
</record>
<record model="gamification.goal.definition" id="definition_favorite_question_1">
<field name="name">Favourite Question (1)</field>
<field name="description">Question set as favorite by 1 user</field>
<field name="computation_mode">count</field>
<field name="display_mode">boolean</field>
<field name="model_id" ref="website_forum.model_forum_post"/>
<field name="domain">[('parent_id', '=', False), ('favourite_count', '>=', 1)]</field>
<field name="condition">higher</field>
<field name="batch_mode">True</field>
<field name="batch_distinctive_field" ref="website_forum.field_forum_post__create_uid"/>
<field name="batch_user_expression">user.id</field>
</record>
<record model="gamification.challenge" id="challenge_favorite_question_1">
<field name="name">Credible Question</field>
<field name="period">once</field>
<field name="visibility_mode">personal</field>
<field name="report_message_frequency">never</field>
<field name="reward_id" ref="badge_q_4"/>
<field name="reward_realtime">True</field>
<field name="user_domain">[('karma', '>', 0)]</field>
<field name="state">inprogress</field>
<field name="challenge_category">forum</field>
</record>
<record model="gamification.challenge.line" id="line_favorite_question_1">
<field name="definition_id" ref="definition_favorite_question_1"/>
<field name="challenge_id" ref="challenge_favorite_question_1"/>
<field name="target_goal">1</field>
</record>
<!-- Favorite: at least 5 users have it in favorite -->
<record id="badge_q_5" model="gamification.badge">
<field name="name">Favorite Question</field>
<field name="description">Question set as favorite by 5 users</field>
<field name="level">silver</field>
<field name="rule_auth">nobody</field>
</record>
<record model="gamification.goal.definition" id="definition_favorite_question_5">
<field name="name">Favourite Question (5)</field>
<field name="description">Question set as favorite by 5 user</field>
<field name="computation_mode">count</field>
<field name="display_mode">boolean</field>
<field name="model_id" ref="website_forum.model_forum_post"/>
<field name="domain">[('parent_id', '=', False), ('favourite_count', '>=', 5)]</field>
<field name="condition">higher</field>
<field name="batch_mode">True</field>
<field name="batch_distinctive_field" ref="website_forum.field_forum_post__create_uid"/>
<field name="batch_user_expression">user.id</field>
</record>
<record model="gamification.challenge" id="challenge_favorite_question_5">
<field name="name">Favorite Question</field>
<field name="period">once</field>
<field name="visibility_mode">personal</field>
<field name="report_message_frequency">never</field>
<field name="reward_id" ref="badge_q_5"/>
<field name="reward_realtime">True</field>
<field name="user_domain">[('karma', '>', 0)]</field>
<field name="state">inprogress</field>
<field name="challenge_category">forum</field>
</record>
<record model="gamification.challenge.line" id="line_favorite_question_5">
<field name="definition_id" ref="definition_favorite_question_5"/>
<field name="challenge_id" ref="challenge_favorite_question_5"/>
<field name="target_goal">1</field>
</record>
<!-- Stellar: at least 25 users have it in favorite -->
<record id="badge_q_6" model="gamification.badge">
<field name="name">Stellar Question</field>
<field name="description">Question set as favorite by 25 users</field>
<field name="level">bronze</field>
<field name="rule_auth">nobody</field>
</record>
<record model="gamification.goal.definition" id="definition_stellar_question_25">
<field name="name">Favourite Question (25)</field>
<field name="description">Question set as favorite by 25 user</field>
<field name="computation_mode">count</field>
<field name="display_mode">boolean</field>
<field name="model_id" ref="website_forum.model_forum_post"/>
<field name="domain">[('parent_id', '=', False), ('favourite_count', '>=', 25)]</field>
<field name="condition">higher</field>
<field name="batch_mode">True</field>
<field name="batch_distinctive_field" ref="website_forum.field_forum_post__create_uid"/>
<field name="batch_user_expression">user.id</field>
</record>
<record model="gamification.challenge" id="challenge_stellar_question_25">
<field name="name">Stellar Question</field>
<field name="period">once</field>
<field name="visibility_mode">personal</field>
<field name="report_message_frequency">never</field>
<field name="reward_id" ref="badge_q_6"/>
<field name="reward_realtime">True</field>
<field name="user_domain">[('karma', '>', 0)]</field>
<field name="state">inprogress</field>
<field name="challenge_category">forum</field>
</record>
<record model="gamification.challenge.line" id="line_stellar_question_25">
<field name="definition_id" ref="definition_stellar_question_25"/>
<field name="challenge_id" ref="challenge_stellar_question_25"/>
<field name="target_goal">1</field>
</record>
<!-- QUALITY (VOTES) -->
<!-- Student: at least 1 upvote -->
<record id="badge_q_7" model="gamification.badge">
<field name="name">Student</field>
<field name="description">Asked first question with at least one up vote</field>
<field name="level">gold</field>
<field name="rule_auth">nobody</field>
</record>
<record model="gamification.goal.definition" id="definition_student">
<field name="name">Upvoted question (1)</field>
<field name="description">Asked first question with at least one up vote</field>
<field name="computation_mode">count</field>
<field name="display_mode">boolean</field>
<field name="model_id" ref="website_forum.model_forum_post"/>
<field name="domain">[('parent_id', '=', False), ('vote_count', '>=', 1)]</field>
<field name="condition">higher</field>
<field name="batch_mode">True</field>
<field name="batch_distinctive_field" ref="website_forum.field_forum_post__create_uid"/>
<field name="batch_user_expression">user.id</field>
</record>
<record model="gamification.challenge" id="challenge_student">
<field name="name">Student</field>
<field name="period">once</field>
<field name="visibility_mode">personal</field>
<field name="report_message_frequency">never</field>
<field name="reward_id" ref="badge_q_7"/>
<field name="reward_realtime">True</field>
<field name="user_domain">[('karma', '>', 0)]</field>
<field name="state">inprogress</field>
<field name="challenge_category">forum</field>
</record>
<record model="gamification.challenge.line" id="line_student">
<field name="definition_id" ref="definition_student"/>
<field name="challenge_id" ref="challenge_student"/>
<field name="target_goal">1</field>
</record>
<!-- Nice: at least 4 upvotes -->
<record id="badge_q_8" model="gamification.badge">
<field name="name">Nice Question</field>
<field name="description">Question voted up 4 times</field>
<field name="level">bronze</field>
<field name="rule_auth">nobody</field>
</record>
<record model="gamification.goal.definition" id="definition_nice_question">
<field name="name">Upvoted question (4)</field>
<field name="description">Asked first question with at least 4 up votes</field>
<field name="computation_mode">count</field>
<field name="display_mode">boolean</field>
<field name="model_id" ref="website_forum.model_forum_post"/>
<field name="domain">[('parent_id', '=', False), ('vote_count', '>=', 4)]</field>
<field name="condition">higher</field>
<field name="batch_mode">True</field>
<field name="batch_distinctive_field" ref="website_forum.field_forum_post__create_uid"/>
<field name="batch_user_expression">user.id</field>
</record>
<record model="gamification.challenge" id="challenge_nice_question">
<field name="name">Nice Question</field>
<field name="period">once</field>
<field name="visibility_mode">personal</field>
<field name="report_message_frequency">never</field>
<field name="reward_id" ref="badge_q_8"/>
<field name="reward_realtime">True</field>
<field name="user_domain">[('karma', '>', 0)]</field>
<field name="state">inprogress</field>
<field name="challenge_category">forum</field>
</record>
<record model="gamification.challenge.line" id="line_nice_question">
<field name="definition_id" ref="definition_nice_question"/>
<field name="challenge_id" ref="challenge_nice_question"/>
<field name="target_goal">1</field>
</record>
<!-- Good: at least 6 upvotes -->
<record id="badge_q_9" model="gamification.badge">
<field name="name">Good Question</field>
<field name="description">Question voted up 6 times</field>
<field name="level">silver</field>
<field name="rule_auth">nobody</field>
</record>
<record model="gamification.goal.definition" id="definition_good_question">
<field name="name">Upvoted question (6)</field>
<field name="description">Asked first question with at least 6 up votes</field>
<field name="computation_mode">count</field>
<field name="display_mode">boolean</field>
<field name="model_id" ref="website_forum.model_forum_post"/>
<field name="domain">[('parent_id', '=', False), ('vote_count', '>=', 6)]</field>
<field name="condition">higher</field>
<field name="batch_mode">True</field>
<field name="batch_distinctive_field" ref="website_forum.field_forum_post__create_uid"/>
<field name="batch_user_expression">user.id</field>
</record>
<record model="gamification.challenge" id="challenge_good_question">
<field name="name">Good Question</field>
<field name="period">once</field>
<field name="visibility_mode">personal</field>
<field name="report_message_frequency">never</field>
<field name="reward_id" ref="badge_q_9"/>
<field name="reward_realtime">True</field>
<field name="user_domain">[('karma', '>', 0)]</field>
<field name="state">inprogress</field>
<field name="challenge_category">forum</field>
</record>
<record model="gamification.challenge.line" id="line_good_question">
<field name="definition_id" ref="definition_good_question"/>
<field name="challenge_id" ref="challenge_good_question"/>
<field name="target_goal">1</field>
</record>
<!-- Great: at least 15 upvotes -->
<record id="badge_q_10" model="gamification.badge">
<field name="name">Great Question</field>
<field name="description">Question voted up 15 times</field>
<field name="level">gold</field>
<field name="rule_auth">nobody</field>
</record>
<record model="gamification.goal.definition" id="definition_great_question">
<field name="name">Upvoted question (15)</field>
<field name="description">Asked first question with at least 15 up votes</field>
<field name="computation_mode">count</field>
<field name="display_mode">boolean</field>
<field name="model_id" ref="website_forum.model_forum_post"/>
<field name="domain">[('parent_id', '=', False), ('vote_count', '>=', 15)]</field>
<field name="condition">higher</field>
<field name="batch_mode">True</field>
<field name="batch_distinctive_field" ref="website_forum.field_forum_post__create_uid"/>
<field name="batch_user_expression">user.id</field>
</record>
<record model="gamification.challenge" id="challenge_great_question">
<field name="name">Great Question</field>
<field name="period">once</field>
<field name="visibility_mode">personal</field>
<field name="report_message_frequency">never</field>
<field name="reward_id" ref="badge_q_10"/>
<field name="reward_realtime">True</field>
<field name="user_domain">[('karma', '>', 0)]</field>
<field name="state">inprogress</field>
<field name="challenge_category">forum</field>
</record>
<record model="gamification.challenge.line" id="line_great_question">
<field name="definition_id" ref="definition_great_question"/>
<field name="target_goal">1</field>
<field name="challenge_id" ref="challenge_great_question"/>
</record>
<!-- Question + Answer -->
<record id="badge_26" model="gamification.badge">
<field name="name">Scholar</field>
<field name="description">Asked a question and accepted an answer</field>
<field name="level">gold</field>
<field name="rule_auth">nobody</field>
</record>
<record model="gamification.goal.definition" id="definition_scholar">
<field name="name">Scholar</field>
<field name="description">Ask a question and accept an answer</field>
<field name="computation_mode">count</field>
<field name="display_mode">boolean</field>
<field name="model_id" ref="website_forum.model_forum_post"/>
<field name="domain">[('parent_id', '=', False), ('has_validated_answer', '=', True)]</field>
<field name="condition">higher</field>
<field name="batch_mode">True</field>
<field name="batch_distinctive_field" ref="website_forum.field_forum_post__create_uid"/>
<field name="batch_user_expression">user.id</field>
</record>
<record model="gamification.challenge" id="challenge_scholar">
<field name="name">Scholar</field>
<field name="period">once</field>
<field name="visibility_mode">personal</field>
<field name="report_message_frequency">never</field>
<field name="reward_id" ref="badge_26"/>
<field name="reward_realtime">True</field>
<field name="user_domain">[('karma', '>', 0)]</field>
<field name="state">inprogress</field>
<field name="challenge_category">forum</field>
</record>
<record model="gamification.challenge.line" id="line_scholar">
<field name="definition_id" ref="definition_scholar"/>
<field name="target_goal">1</field>
<field name="challenge_id" ref="challenge_scholar"/>
</record>
</data>
</odoo>

14
data/ir_actions_data.xml Normal file
View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data noupdate="1">
<!-- JUMP TO FORUM AT INSTALL -->
<record id="action_open_forum" model="ir.actions.act_url">
<field name="name">Forum</field>
<field name="target">self</field>
<field name="url" eval="'/forum/'+str(ref('website_forum.forum_help'))"/>
</record>
<record id="base.open_menu" model="ir.actions.todo">
<field name="action_id" ref="action_open_forum"/>
<field name="state">open</field>
</record>
</data></odoo>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data noupdate="1">
<function model="ir.config_parameter" name="set_param" eval="('auth_signup.invitation_scope', 'b2c')"/>
</data></odoo>

View File

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Answers subtypes -->
<record id="mt_answer_new" model="mail.message.subtype">
<field name="name">New Answer</field>
<field name="res_model">forum.post</field>
<field name="default" eval="True"/>
<field name="hidden" eval="False"/>
<field name="description">New Answer</field>
</record>
<record id="mt_answer_edit" model="mail.message.subtype">
<field name="name">Answer Edited</field>
<field name="res_model">forum.post</field>
<field name="default" eval="False"/>
<field name="description">Answer Edited</field>
</record>
<!-- Questions subtypes -->
<record id="mt_question_new" model="mail.message.subtype">
<field name="name">New Question</field>
<field name="res_model">forum.post</field>
<field name="default" eval="True"/>
<field name="description">New Question</field>
</record>
<record id="mt_question_edit" model="mail.message.subtype">
<field name="name">Question Edited</field>
<field name="res_model">forum.post</field>
<field name="default" eval="False"/>
<field name="description">Question Edited</field>
</record>
<!-- Forum subtypes, to follow all answers or questions -->
<record id="mt_forum_answer_new" model="mail.message.subtype">
<field name="name">New Answer</field>
<field name="res_model">forum.forum</field>
<field name="default" eval="True"/>
<field name="hidden" eval="False"/>
<field name="parent_id" ref="mt_answer_new"/>
<field name="relation_field">forum_id</field>
</record>
<record id="mt_forum_question_new" model="mail.message.subtype">
<field name="name">New Question</field>
<field name="res_model">forum.forum</field>
<field name="default" eval="True"/>
<field name="hidden" eval="False"/>
<field name="parent_id" ref="mt_question_new"/>
<field name="relation_field">forum_id</field>
</record>
</data>
</odoo>

32
data/mail_templates.xml Normal file
View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data noupdate="1">
<template id="forum_post_template_new_answer">
<p>A new answer on <t t-esc="object.name"/> has been posted. Click here to access the post :</p>
<p style="margin-left: 30px; margin-top: 10 px; margin-bottom: 10px;">
<a t-attf-href="/forum/#{slug(object.forum_id)}/#{slug(object)}"
style="padding: 5px 10px; font-size: 12px; line-height: 18px; color: #FFFFFF; border-color:#875A7B; text-decoration: none; display: inline-block; margin-bottom: 0px; font-weight: 400; text-align: center; vertical-align: middle; cursor: pointer; background-color: #875A7B; border: 1px solid #875A7B; border-radius:3px">
See post
</a>
</p>
</template>
<template id="forum_post_template_new_question">
<p>A new question <b t-esc="object.name" /> on <t t-esc="object.forum_id.name"/> has been posted. Click here to access the question :</p>
<p style="margin-left: 30px; margin-top: 10 px; margin-bottom: 10px;">
<a t-attf-href="/forum/#{slug(object.forum_id)}/#{slug(object)}"
style="padding: 5px 10px; font-size: 12px; line-height: 18px; color: #FFFFFF; border-color:#875A7B; text-decoration: none; display: inline-block; margin-bottom: 0px; font-weight: 400; text-align: center; vertical-align: middle; cursor: pointer;background-color: #875A7B; border: 1px solid #875A7B; border-radius:3px">
See question
</a>
</p>
</template>
<template id="forum_post_template_validation">
<p>A new question <b t-esc="object.name" /> on <t t-esc="object.forum_id.name"/> has been posted and require your validation. Click here to access the question :</p>
<p style="margin-left: 30px; margin-top: 10 px; margin-bottom: 10px;">
<a t-attf-href="/forum/#{slug(object.forum_id)}/#{slug(object)}"
style="padding: 5px 10px; font-size: 12px; line-height: 18px; color: #FFFFFF; border-color:#875A7B; text-decoration: none; display: inline-block; margin-bottom: 0px; font-weight: 400; text-align: center; vertical-align: middle; cursor: pointer;background-color: #875A7B; border: 1px solid #875A7B; border-radius:3px">
Validate question
</a>
</p>
</template>
</data></odoo>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data noupdate="1">
<record id="menu_website_forums" model="website.menu">
<field name="name">Forum</field>
<field name="url">/forum</field>
<field name="parent_id" ref="website.main_menu"/>
<field name="sequence" type="int">35</field>
</record>
</data></odoo>

3983
i18n/af.po Normal file

File diff suppressed because it is too large Load Diff

4222
i18n/ar.po Normal file

File diff suppressed because it is too large Load Diff

3984
i18n/az.po Normal file

File diff suppressed because it is too large Load Diff

4141
i18n/bg.po Normal file

File diff suppressed because it is too large Load Diff

3985
i18n/bs.po Normal file

File diff suppressed because it is too large Load Diff

4228
i18n/ca.po Normal file

File diff suppressed because it is too large Load Diff

4187
i18n/cs.po Normal file

File diff suppressed because it is too large Load Diff

4189
i18n/da.po Normal file

File diff suppressed because it is too large Load Diff

4286
i18n/de.po Normal file

File diff suppressed because it is too large Load Diff

3989
i18n/el.po Normal file

File diff suppressed because it is too large Load Diff

3982
i18n/en_GB.po Normal file

File diff suppressed because it is too large Load Diff

4263
i18n/es.po Normal file

File diff suppressed because it is too large Load Diff

4264
i18n/es_419.po Normal file

File diff suppressed because it is too large Load Diff

3992
i18n/es_CO.po Normal file

File diff suppressed because it is too large Load Diff

3981
i18n/es_DO.po Normal file

File diff suppressed because it is too large Load Diff

3997
i18n/es_EC.po Normal file

File diff suppressed because it is too large Load Diff

3982
i18n/es_PE.po Normal file

File diff suppressed because it is too large Load Diff

4199
i18n/et.po Normal file

File diff suppressed because it is too large Load Diff

4195
i18n/fa.po Normal file

File diff suppressed because it is too large Load Diff

4233
i18n/fi.po Normal file

File diff suppressed because it is too large Load Diff

4266
i18n/fr.po Normal file

File diff suppressed because it is too large Load Diff

3987
i18n/gu.po Normal file

File diff suppressed because it is too large Load Diff

4146
i18n/he.po Normal file

File diff suppressed because it is too large Load Diff

4003
i18n/hr.po Normal file

File diff suppressed because it is too large Load Diff

4147
i18n/hu.po Normal file

File diff suppressed because it is too large Load Diff

4248
i18n/id.po Normal file

File diff suppressed because it is too large Load Diff

3979
i18n/is.po Normal file

File diff suppressed because it is too large Load Diff

4252
i18n/it.po Normal file

File diff suppressed because it is too large Load Diff

4164
i18n/ja.po Normal file

File diff suppressed because it is too large Load Diff

3981
i18n/ka.po Normal file

File diff suppressed because it is too large Load Diff

3981
i18n/kab.po Normal file

File diff suppressed because it is too large Load Diff

3986
i18n/km.po Normal file

File diff suppressed because it is too large Load Diff

4170
i18n/ko.po Normal file

File diff suppressed because it is too large Load Diff

3979
i18n/lb.po Normal file

File diff suppressed because it is too large Load Diff

4144
i18n/lt.po Normal file

File diff suppressed because it is too large Load Diff

4093
i18n/lv.po Normal file

File diff suppressed because it is too large Load Diff

3991
i18n/mk.po Normal file

File diff suppressed because it is too large Load Diff

4019
i18n/mn.po Normal file

File diff suppressed because it is too large Load Diff

3995
i18n/nb.po Normal file

File diff suppressed because it is too large Load Diff

4255
i18n/nl.po Normal file

File diff suppressed because it is too large Load Diff

4205
i18n/pl.po Normal file

File diff suppressed because it is too large Load Diff

4090
i18n/pt.po Normal file

File diff suppressed because it is too large Load Diff

4260
i18n/pt_BR.po Normal file

File diff suppressed because it is too large Load Diff

4026
i18n/ro.po Normal file

File diff suppressed because it is too large Load Diff

4264
i18n/ru.po Normal file

File diff suppressed because it is too large Load Diff

4123
i18n/sk.po Normal file

File diff suppressed because it is too large Load Diff

4096
i18n/sl.po Normal file

File diff suppressed because it is too large Load Diff

4200
i18n/sr.po Normal file

File diff suppressed because it is too large Load Diff

3985
i18n/sr@latin.po Normal file

File diff suppressed because it is too large Load Diff

4107
i18n/sv.po Normal file

File diff suppressed because it is too large Load Diff

4225
i18n/th.po Normal file

File diff suppressed because it is too large Load Diff

4199
i18n/tr.po Normal file

File diff suppressed because it is too large Load Diff

4194
i18n/uk.po Normal file

File diff suppressed because it is too large Load Diff

4188
i18n/vi.po Normal file

File diff suppressed because it is too large Load Diff

4082
i18n/website_forum.pot Normal file

File diff suppressed because it is too large Load Diff

4155
i18n/zh_CN.po Normal file

File diff suppressed because it is too large Load Diff

4142
i18n/zh_TW.po Normal file

File diff suppressed because it is too large Load Diff

12
models/__init__.py Normal file
View File

@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
from . import forum_forum
from . import forum_post
from . import forum_post_reason
from . import forum_post_vote
from . import forum_tag
from . import gamification_challenge
from . import gamification_karma_tracking
from . import ir_attachment
from . import res_users
from . import website

362
models/forum_forum.py Normal file
View File

@ -0,0 +1,362 @@
# -*- coding: utf-8 -*-
# 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.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):
_name = 'forum.forum'
_description = 'Forum'
_inherit = [
'mail.thread',
'image.mixin',
'website.seo.metadata',
'website.multi.mixin',
'website.searchable.mixin',
]
_order = "sequence, id"
@api.model
def _get_default_welcome_message(self):
return Markup("""
<h2 class="display-3-fs" style="text-align: center;clear-both;font-weight: bold;">%(message_intro)s</h2>
<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)
sequence = fields.Integer('Sequence', default=1)
mode = fields.Selection([
('questions', 'Questions (1 answer)'),
('discussions', 'Discussions (multiple answers)')],
string='Mode', required=True, default='questions',
help='Questions mode: only one answer allowed\n Discussions mode: multiple answers allowed')
privacy = fields.Selection([
('public', 'Public'),
('connected', 'Signed In'),
('private', 'Some users')],
help="Public: Forum is public\nSigned In: Forum is visible for signed in users\nSome users: Forum and their content are hidden for non members of selected group",
default='public')
authorized_group_id = fields.Many2one('res.groups', 'Authorized Group')
menu_id = fields.Many2one('website.menu', 'Menu', copy=False)
active = fields.Boolean(default=True)
faq = fields.Html(
'Guidelines', translate=html_translate,
sanitize=True, sanitize_overridable=True)
description = fields.Text('Description', translate=True)
teaser = fields.Text('Teaser', compute='_compute_teaser', store=True)
welcome_message = fields.Html(
'Welcome Message', translate=html_translate,
default=_get_default_welcome_message,
sanitize_attributes=False, sanitize_form=False)
default_order = fields.Selection([
('create_date desc', 'Newest'),
('last_activity_date desc', 'Last Updated'),
('vote_count desc', 'Most Voted'),
('relevancy desc', 'Relevance'),
('child_count desc', 'Answered')],
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_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 '
'of the forum content.')
# posts statistics
post_ids = fields.One2many('forum.post', 'forum_id', string='Posts')
last_post_id = fields.Many2one('forum.post', compute='_compute_last_post_id')
total_posts = fields.Integer('# Posts', compute='_compute_forum_statistics')
total_views = fields.Integer('# Views', compute='_compute_forum_statistics')
total_answers = fields.Integer('# Answers', compute='_compute_forum_statistics')
total_favorites = fields.Integer('# Favorites', compute='_compute_forum_statistics')
count_posts_waiting_validation = fields.Integer(string="Number of posts waiting for validation", compute='_compute_count_posts_waiting_validation')
count_flagged_posts = fields.Integer(string='Number of flagged posts', compute='_compute_count_flagged_posts')
# karma generation
karma_gen_question_new = fields.Integer(string='Asking a question', default=2)
karma_gen_question_upvote = fields.Integer(string='Question upvoted', default=5)
karma_gen_question_downvote = fields.Integer(string='Question downvoted', default=-2)
karma_gen_answer_upvote = fields.Integer(string='Answer upvoted', default=10)
karma_gen_answer_downvote = fields.Integer(string='Answer downvoted', default=-2)
karma_gen_answer_accept = fields.Integer(string='Accepting an answer', default=2)
karma_gen_answer_accepted = fields.Integer(string='Answer accepted', default=15)
karma_gen_answer_flagged = fields.Integer(string='Answer flagged', default=-100)
# karma-based actions
karma_ask = fields.Integer(string='Ask questions', default=3)
karma_answer = fields.Integer(string='Answer questions', default=3)
karma_edit_own = fields.Integer(string='Edit own posts', default=1)
karma_edit_all = fields.Integer(string='Edit all posts', default=300)
karma_edit_retag = fields.Integer(string='Change question tags', default=75)
karma_close_own = fields.Integer(string='Close own posts', default=100)
karma_close_all = fields.Integer(string='Close all posts', default=500)
karma_unlink_own = fields.Integer(string='Delete own posts', default=500)
karma_unlink_all = fields.Integer(string='Delete all posts', default=1000)
karma_tag_create = fields.Integer(string='Create new tags', default=30)
karma_upvote = fields.Integer(string='Upvote', default=5)
karma_downvote = fields.Integer(string='Downvote', default=50)
karma_answer_accept_own = fields.Integer(string='Accept an answer on own questions', default=20)
karma_answer_accept_all = fields.Integer(string='Accept an answer to all questions', default=500)
karma_comment_own = fields.Integer(string='Comment own posts', default=1)
karma_comment_all = fields.Integer(string='Comment all posts', default=1)
karma_comment_convert_own = fields.Integer(string='Convert own answers to comments and vice versa', default=50)
karma_comment_convert_all = fields.Integer(string='Convert all answers to comments and vice versa', default=500)
karma_comment_unlink_own = fields.Integer(string='Unlink own comments', default=50)
karma_comment_unlink_all = fields.Integer(string='Unlink all comments', default=500)
karma_flag = fields.Integer(string='Flag a post as offensive', default=500)
karma_dofollow = fields.Integer(string='Nofollow links', help='If the author has not enough karma, a nofollow attribute is added to links', default=500)
karma_editor = fields.Integer(string='Editor Features: image and links',
default=30)
karma_user_bio = fields.Integer(string='Display detailed user biography', default=750)
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):
domain = [
('create_uid', '=', self.env.user.id),
('state', '=', 'pending'),
('parent_id', '=', False),
]
pending_forums = self.env['forum.forum'].search([
('id', 'in', self.ids),
('post_ids', 'any', domain),
])
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:
forum.teaser = textwrap.shorten(forum.description, width=180, placeholder='...') if forum.description else ""
@api.depends('post_ids')
def _compute_last_post_id(self):
last_forums_posts = self.env['forum.post']._read_group(
[('forum_id', 'in', self.ids), ('parent_id', '=', False), ('state', '=', 'active')],
groupby=['forum_id'], aggregates=['id:max'],
)
forum_to_last_post_id = {forum.id: last_post_id for forum, last_post_id in last_forums_posts}
for forum in self:
forum.last_post_id = forum_to_last_post_id.get(forum.id, False)
@api.depends('post_ids.state', 'post_ids.views', 'post_ids.child_count', 'post_ids.favourite_count')
def _compute_forum_statistics(self):
default_stats = {'total_posts': 0, 'total_views': 0, 'total_answers': 0, 'total_favorites': 0}
if not self.ids:
self.update(default_stats)
return
result = {cid: dict(default_stats) for cid in self.ids}
read_group_res = self.env['forum.post']._read_group(
[('forum_id', 'in', self.ids), ('state', 'in', ('active', 'close')), ('parent_id', '=', False)],
['forum_id'],
['__count', 'views:sum', 'child_count:sum', 'favourite_count:sum'])
for forum, count, views_sum, child_count_sum, favourite_count_sum in read_group_res:
stat_forum = result[forum.id]
stat_forum['total_posts'] += count
stat_forum['total_views'] += views_sum
stat_forum['total_answers'] += child_count_sum
stat_forum['total_favorites'] += 1 if favourite_count_sum else 0
for record in self:
record.update(result[record.id])
def _compute_count_posts_waiting_validation(self):
for forum in self:
domain = [('forum_id', '=', forum.id), ('state', '=', 'pending')]
forum.count_posts_waiting_validation = self.env['forum.post'].search_count(domain)
def _compute_count_flagged_posts(self):
for forum in self:
domain = [('forum_id', '=', forum.id), ('state', '=', 'flagged')]
forum.count_flagged_posts = self.env['forum.post'].search_count(domain)
# EXTENDS WEBSITE.MULTI.MIXIN
def _compute_website_url(self):
if not self.id:
return False
return f'/forum/{slug(self)}'
# ----------------------------------------------------------------------
# CRUD
# ----------------------------------------------------------------------
@api.model_create_multi
def create(self, vals_list):
forums = super(
Forum,
self.with_context(mail_create_nolog=True, mail_create_nosubscribe=True)
).create(vals_list)
self.env['website'].sudo()._update_forum_count()
forums._set_default_faq()
return forums
def unlink(self):
self.env['website'].sudo()._update_forum_count()
return super().unlink()
def write(self, vals):
if 'privacy' in vals:
if not vals['privacy']:
# The forum is neither public, neither private, remove menu to avoid conflict
self.menu_id.unlink()
elif vals['privacy'] == 'public':
# The forum is public, the menu must be also public
vals['authorized_group_id'] = False
elif vals['privacy'] == 'connected':
vals['authorized_group_id'] = False
res = super().write(vals)
if 'active' in vals:
# archiving/unarchiving a forum does it on its posts, too
self.env['forum.post'].with_context(active_test=False).search([('forum_id', 'in', self.ids)]).write({'active': vals['active']})
if 'active' in vals or 'website_id' in vals:
self.env['website'].sudo()._update_forum_count()
return res
def _set_default_faq(self):
for forum in self:
forum.faq = self.env['ir.ui.view']._render_template('website_forum.faq_accordion', {"forum": forum})
# ----------------------------------------------------------------------
# TOOLS
# ----------------------------------------------------------------------
def _tag_to_write_vals(self, tags=''):
Tag = self.env['forum.tag']
post_tags = []
existing_keep = []
user = self.env.user
for tag_id_or_new_name in (tag.strip() for tag in tags.split(',') if tag and tag.strip()):
if tag_id_or_new_name.startswith('_'): # it's a new tag
tag_name = tag_id_or_new_name[1:]
# check that not already created meanwhile or maybe excluded by the limit on the search
tag_ids = Tag.search([('name', '=', tag_name), ('forum_id', '=', self.id)], limit=1)
if tag_ids:
existing_keep.append(tag_ids.id)
else:
# check if user have Karma needed to create need tag
if user.exists() and user.karma >= self.karma_tag_create and tag_name:
post_tags.append((0, 0, {'name': tag_name, 'forum_id': self.id}))
else:
existing_keep.append(int(tag_id_or_new_name))
post_tags.insert(0, [6, 0, existing_keep])
return post_tags
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
# ----------------------------------------------------------------------
def go_to_website(self):
self.ensure_one()
website_url = self._compute_website_url()
if not website_url:
return False
return self.env['website'].get_client_action(self._compute_website_url())
@api.model
def _search_get_detail(self, website, order, options):
with_description = options['displayDescription']
search_fields = ['name']
fetch_fields = ['id', 'name']
mapping = {
'name': {'name': 'name', 'type': 'text', 'match': True},
'website_url': {'name': 'website_url', 'type': 'text', 'truncate': False},
}
if with_description:
search_fields.append('description')
fetch_fields.append('description')
mapping['description'] = {'name': 'description', 'type': 'text', 'match': True}
return {
'model': 'forum.forum',
'base_domain': [website.website_domain()],
'search_fields': search_fields,
'fetch_fields': fetch_fields,
'mapping': mapping,
'icon': 'fa-comments-o',
'order': 'name desc, id desc' if 'name desc' in order else 'name asc, id desc',
}
def _search_render_results(self, fetch_fields, mapping, icon, limit):
results_data = super()._search_render_results(fetch_fields, mapping, icon, limit)
for forum, data in zip(self, results_data):
data['website_url'] = forum._compute_website_url()
return results_data

870
models/forum_post.py Normal file
View File

@ -0,0 +1,870 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
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.osv import expression
from odoo.tools import sql
_logger = logging.getLogger(__name__)
class Post(models.Model):
_name = 'forum.post'
_description = 'Forum Post'
_inherit = [
'mail.thread',
'website.seo.metadata',
'website.searchable.mixin',
]
_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)
content = fields.Html('Content', strip_style=True)
plain_content = fields.Text(
'Plain Content',
compute='_compute_plain_content', store=True)
tag_ids = fields.Many2many('forum.tag', 'forum_tag_rel', 'forum_id', 'forum_tag_id', string='Tags')
state = fields.Selection(
[
('active', 'Active'), ('pending', 'Waiting Validation'),
('close', 'Closed'), ('offensive', 'Offensive'),
('flagged', 'Flagged'),
], string='Status', default='active')
views = fields.Integer('Views', default=0, readonly=True, copy=False)
active = fields.Boolean('Active', default=True)
website_message_ids = fields.One2many(domain=lambda self: [('model', '=', self._name), ('message_type', 'in', ['email', 'comment', 'email_outgoing'])])
website_url = fields.Char('Website URL', compute='_compute_website_url')
website_id = fields.Many2one(related='forum_id.website_id', readonly=True)
# history
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)
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)
# vote
vote_ids = fields.One2many('forum.post.vote', 'post_id', string='Votes')
user_vote = fields.Integer('My Vote', compute='_compute_user_vote')
vote_count = fields.Integer('Total Votes', compute='_compute_vote_count', store=True)
# favorite
favourite_ids = fields.Many2many('res.users', string='Favourite')
user_favourite = fields.Boolean('Is Favourite', compute='_compute_user_favourite')
favourite_count = fields.Integer('Favorite', compute='_compute_favorite_count', store=True)
# hierarchy
is_correct = fields.Boolean('Correct', help='Correct answer or answer accepted')
parent_id = fields.Many2one(
'forum.post', string='Question',
ondelete='cascade', readonly=True, index=True)
self_reply = fields.Boolean('Reply to own question', compute='_compute_self_reply', store=True)
child_ids = fields.One2many(
'forum.post', 'parent_id', string='Post Answers',
domain="[('forum_id', '=', forum_id)]")
child_count = fields.Integer('Answers', compute='_compute_child_count', store=True)
uid_has_answered = fields.Boolean('Has Answered', compute='_compute_uid_has_answered')
has_validated_answer = fields.Boolean(
'Is answered',
compute='_compute_has_validated_answer', store=True)
# offensive moderation tools
flag_user_id = fields.Many2one('res.users', string='Flagged by')
moderator_id = fields.Many2one('res.users', string='Reviewed by', readonly=True)
# closing
closed_reason_id = fields.Many2one('forum.post.reason', string='Reason', copy=False)
closed_uid = fields.Many2one('res.users', string='Closed by', readonly=True, copy=False)
closed_date = fields.Datetime('Closed on', readonly=True, copy=False)
# karma calculation and access
karma_accept = fields.Integer(
'Convert comment to answer',
compute='_compute_post_karma_rights', compute_sudo=False)
karma_edit = fields.Integer(
'Karma to edit',
compute='_compute_post_karma_rights', compute_sudo=False)
karma_close = fields.Integer(
'Karma to close',
compute='_compute_post_karma_rights', compute_sudo=False)
karma_unlink = fields.Integer(
'Karma to unlink',
compute='_compute_post_karma_rights', compute_sudo=False)
karma_comment = fields.Integer(
'Karma to comment',
compute='_compute_post_karma_rights', compute_sudo=False)
karma_comment_convert = fields.Integer(
'Karma to convert comment to answer',
compute='_compute_post_karma_rights', compute_sudo=False)
karma_flag = fields.Integer(
'Flag a post as offensive',
compute='_compute_post_karma_rights', compute_sudo=False)
can_ask = fields.Boolean(
'Can Ask',
compute='_compute_post_karma_rights', compute_sudo=False)
can_answer = fields.Boolean(
'Can Answer',
compute='_compute_post_karma_rights', compute_sudo=False)
can_accept = fields.Boolean(
'Can Accept',
compute='_compute_post_karma_rights', compute_sudo=False)
can_edit = fields.Boolean(
'Can Edit',
compute='_compute_post_karma_rights', compute_sudo=False)
can_close = fields.Boolean(
'Can Close',
compute='_compute_post_karma_rights', compute_sudo=False)
can_unlink = fields.Boolean(
'Can Unlink',
compute='_compute_post_karma_rights', compute_sudo=False)
can_upvote = fields.Boolean(
'Can Upvote',
compute='_compute_post_karma_rights', compute_sudo=False)
can_downvote = fields.Boolean(
'Can Downvote',
compute='_compute_post_karma_rights', compute_sudo=False)
can_comment = fields.Boolean(
'Can Comment',
compute='_compute_post_karma_rights', compute_sudo=False)
can_comment_convert = fields.Boolean(
'Can Convert to Comment',
compute='_compute_post_karma_rights', compute_sudo=False)
can_view = fields.Boolean(
'Can View',
compute='_compute_post_karma_rights', compute_sudo=False, search='_search_can_view')
can_display_biography = fields.Boolean(
"Is the author's biography visible from his post",
compute='_compute_post_karma_rights', compute_sudo=False)
can_post = fields.Boolean(
'Can Automatically be Validated',
compute='_compute_post_karma_rights', compute_sudo=False)
can_flag = fields.Boolean(
'Can Flag',
compute='_compute_post_karma_rights', compute_sudo=False)
can_moderate = fields.Boolean(
'Can Moderate',
compute='_compute_post_karma_rights', compute_sudo=False)
can_use_full_editor = fields.Boolean( # Editor Features: image and links
'Can Use Full Editor',
compute='_compute_post_karma_rights', compute_sudo=False)
@api.constrains('parent_id')
def _check_parent_id(self):
if not self._check_recursion():
raise ValidationError(_('You cannot create recursive forum posts.'))
@api.depends('content')
def _compute_plain_content(self):
for post in self:
post.plain_content = tools.html2plaintext(post.content)[0:500] if post.content else False
@api.depends('name')
def _compute_website_url(self):
self.website_url = False
for post in self.filtered(lambda post: post.id):
anchor = f'#answer_{post.id}' if post.parent_id else ''
post.website_url = f'/forum/{slug(post.forum_id)}/{slug(post)}{anchor}'
@api.depends('vote_count', 'forum_id.relevancy_post_vote', 'forum_id.relevancy_time_decay')
def _compute_relevancy(self):
for post in self:
if post.create_date:
days = (datetime.today() - post.create_date).days
post.relevancy = math.copysign(1, post.vote_count) * (abs(post.vote_count - 1) ** post.forum_id.relevancy_post_vote / (days + 2) ** post.forum_id.relevancy_time_decay)
else:
post.relevancy = 0
@api.depends_context('uid')
def _compute_user_vote(self):
votes = self.env['forum.post.vote'].search_read([('post_id', 'in', self._ids), ('user_id', '=', self._uid)], ['vote', 'post_id'])
mapped_vote = dict([(v['post_id'][0], v['vote']) for v in votes])
for vote in self:
vote.user_vote = mapped_vote.get(vote.id, 0)
@api.depends('vote_ids.vote')
def _compute_vote_count(self):
read_group_res = self.env['forum.post.vote']._read_group([('post_id', 'in', self._ids)], ['post_id', 'vote'], ['__count'])
result = dict.fromkeys(self._ids, 0)
for post, vote, count in read_group_res:
result[post.id] += count * int(vote)
for post in self:
post.vote_count = result[post.id]
@api.depends_context('uid')
def _compute_user_favourite(self):
for post in self:
post.user_favourite = post._uid in post.favourite_ids.ids
@api.depends('favourite_ids')
def _compute_favorite_count(self):
for post in self:
post.favourite_count = len(post.favourite_ids)
@api.depends('create_uid', 'parent_id')
def _compute_self_reply(self):
for post in self:
post.self_reply = post.parent_id.create_uid == post.create_uid
@api.depends('child_ids')
def _compute_child_count(self):
for post in self:
post.child_count = len(post.child_ids)
@api.depends_context('uid')
def _compute_uid_has_answered(self):
for post in self:
post.uid_has_answered = post._uid in post.child_ids.create_uid.ids
@api.depends('child_ids.is_correct')
def _compute_has_validated_answer(self):
for post in self:
post.has_validated_answer = any(answer.is_correct for answer in post.child_ids)
@api.depends_context('uid')
def _compute_post_karma_rights(self):
user = self.env.user
is_admin = self.env.is_admin()
# sudoed recordset instead of individual posts so values can be
# prefetched in bulk
for post, post_sudo in zip(self, self.sudo()):
is_creator = post.create_uid == user
post.karma_accept = post.forum_id.karma_answer_accept_own if post.parent_id.create_uid == user else post.forum_id.karma_answer_accept_all
post.karma_edit = post.forum_id.karma_edit_own if is_creator else post.forum_id.karma_edit_all
post.karma_close = post.forum_id.karma_close_own if is_creator else post.forum_id.karma_close_all
post.karma_unlink = post.forum_id.karma_unlink_own if is_creator else post.forum_id.karma_unlink_all
post.karma_comment = post.forum_id.karma_comment_own if is_creator else post.forum_id.karma_comment_all
post.karma_comment_convert = post.forum_id.karma_comment_convert_own if is_creator else post.forum_id.karma_comment_convert_all
post.karma_flag = post.forum_id.karma_flag
post.can_ask = is_admin or user.karma >= post.forum_id.karma_ask
post.can_answer = is_admin or user.karma >= post.forum_id.karma_answer
post.can_accept = is_admin or user.karma >= post.karma_accept
post.can_edit = is_admin or user.karma >= post.karma_edit
post.can_close = is_admin or user.karma >= post.karma_close
post.can_unlink = is_admin or user.karma >= post.karma_unlink
post.can_upvote = is_admin or user.karma >= post.forum_id.karma_upvote or post.user_vote == -1
post.can_downvote = is_admin or user.karma >= post.forum_id.karma_downvote or post.user_vote == 1
post.can_comment = is_admin or user.karma >= post.karma_comment
post.can_comment_convert = is_admin or user.karma >= post.karma_comment_convert
post.can_view = post.can_close or post_sudo.active and (post_sudo.create_uid.karma > 0 or post_sudo.create_uid == user)
post.can_display_biography = is_admin or (post_sudo.create_uid.karma >= post.forum_id.karma_user_bio and post_sudo.create_uid.website_published)
post.can_post = is_admin or user.karma >= post.forum_id.karma_post
post.can_flag = is_admin or user.karma >= post.forum_id.karma_flag
post.can_moderate = is_admin or user.karma >= post.forum_id.karma_moderate
post.can_use_full_editor = is_admin or user.karma >= post.forum_id.karma_editor
def _search_can_view(self, operator, value):
if operator not in ('=', '!=', '<>'):
raise ValueError('Invalid operator: %s' % (operator,))
if not value:
operator = '!=' if operator == '=' else '='
user = self.env.user
# Won't impact sitemap, search() in converter is forced as public user
if self.env.is_admin():
return [(1, '=', 1)]
req = """
SELECT p.id
FROM forum_post p
LEFT JOIN res_users u ON p.create_uid = u.id
LEFT JOIN forum_forum f ON p.forum_id = f.id
WHERE
(p.create_uid = %s and f.karma_close_own <= %s)
or (p.create_uid != %s and f.karma_close_all <= %s)
or (
u.karma > 0
and (p.active or p.create_uid = %s)
)
"""
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)))]
# EXTENDS WEBSITE.SEO.METADATA
def _default_website_meta(self):
res = super(Post, self)._default_website_meta()
res['default_opengraph']['og:title'] = res['default_twitter']['twitter:title'] = self.name
res['default_opengraph']['og:description'] = res['default_twitter']['twitter:description'] = self.plain_content
res['default_opengraph']['og:image'] = res['default_twitter']['twitter:image'] = self.env['website'].image_url(self.create_uid, 'image_1024')
res['default_twitter']['twitter:card'] = 'summary'
res['default_meta_description'] = self.plain_content
return res
# ----------------------------------------------------------------------
# CRUD
# ----------------------------------------------------------------------
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if 'content' in vals and vals.get('forum_id'):
vals['content'] = self._update_content(vals['content'], vals['forum_id'])
posts = super(Post, self.with_context(mail_create_nolog=True)).create(vals_list)
for post in posts:
# deleted or closed questions
if post.parent_id and (post.parent_id.state == 'close' or post.parent_id.active is False):
raise UserError(_('Posting answer on a [Deleted] or [Closed] question is not possible.'))
# karma-based access
if not post.parent_id and not post.can_ask:
raise AccessError(_('%d karma required to create a new question.', post.forum_id.karma_ask))
elif post.parent_id and not post.can_answer:
raise AccessError(_('%d karma required to answer a question.', post.forum_id.karma_answer))
if not post.parent_id and not post.can_post:
post.sudo().state = 'pending'
# add karma for posting new questions
if not post.parent_id and post.state == 'active':
post.create_uid.sudo()._add_karma(post.forum_id.karma_gen_question_new, post, _('Ask a new question'))
posts.post_notification()
return posts
def unlink(self):
# if unlinking an answer with accepted answer: remove provided karma
for post in self:
if post.is_correct:
post.create_uid.sudo()._add_karma(post.forum_id.karma_gen_answer_accepted * -1, post, _('The accepted answer is deleted'))
self.env.user.sudo()._add_karma(post.forum_id.karma_gen_answer_accepted * -1, post, _('Delete the accepted answer'))
return super(Post, self).unlink()
def write(self, vals):
trusted_keys = ['active', 'is_correct', 'tag_ids'] # fields where security is checked manually
if 'content' in vals:
vals['content'] = self._update_content(vals['content'], self.forum_id.id)
tag_ids = False
if 'tag_ids' in vals:
tag_ids = set(self.new({'tag_ids': vals['tag_ids']}).tag_ids.ids)
for post in self:
if 'state' in vals:
if vals['state'] in ['active', 'close']:
if not post.can_close:
raise AccessError(_('%d karma required to close or reopen a post.', post.karma_close))
trusted_keys += ['state', 'closed_uid', 'closed_date', 'closed_reason_id']
elif vals['state'] == 'flagged':
if not post.can_flag:
raise AccessError(_('%d karma required to flag a post.', post.forum_id.karma_flag))
trusted_keys += ['state', 'flag_user_id']
if 'active' in vals:
if not post.can_unlink:
raise AccessError(_('%d karma required to delete or reactivate a post.', post.karma_unlink))
if 'is_correct' in vals:
if not post.can_accept:
raise AccessError(_('%d karma required to accept or refuse an answer.', post.karma_accept))
# update karma except for self-acceptance
mult = 1 if vals['is_correct'] else -1
if vals['is_correct'] != post.is_correct and post.create_uid.id != self._uid:
post.create_uid.sudo()._add_karma(post.forum_id.karma_gen_answer_accepted * mult, post,
_('User answer accepted') if mult > 0 else _('Accepted answer removed'))
self.env.user.sudo()._add_karma(post.forum_id.karma_gen_answer_accept * mult, post,
_('Validate an answer') if mult > 0 else _('Remove validated answer'))
if tag_ids:
if set(post.tag_ids.ids) != tag_ids and self.env.user.karma < post.forum_id.karma_edit_retag:
raise AccessError(_('%d karma required to retag.', post.forum_id.karma_edit_retag))
if any(key not in trusted_keys for key in vals) and not post.can_edit:
raise AccessError(_('%d karma required to edit a post.', post.karma_edit))
res = super(Post, self).write(vals)
# if post content modify, notify followers
if 'content' in vals or 'name' in vals:
for post in self:
if post.parent_id:
body, subtype_xmlid = _('Answer Edited'), 'website_forum.mt_answer_edit'
obj_id = post.parent_id
else:
body, subtype_xmlid = _('Question Edited'), 'website_forum.mt_question_edit'
obj_id = post
obj_id.message_post(body=body, subtype_xmlid=subtype_xmlid)
if 'active' in vals:
answers = self.env['forum.post'].with_context(active_test=False).search([('parent_id', 'in', self.ids)])
if answers:
answers.write({'active': vals['active']})
return res
def _get_access_action(self, access_uid=None, force_website=False):
""" Instead of the classic form view, redirect to the post on the website directly """
self.ensure_one()
if not force_website and not self.state == 'active':
return super(Post, self)._get_access_action(access_uid=access_uid, force_website=force_website)
return {
'type': 'ir.actions.act_url',
'url': '/forum/%s/%s' % (self.forum_id.id, self.id),
'target': 'self',
'target_type': 'public',
'res_id': self.id,
}
@api.ondelete(at_uninstall=False)
def _unlink_if_enough_karma(self):
for post in self:
if not post.can_unlink:
raise AccessError(_('%d karma required to unlink a post.', post.karma_unlink))
def _update_content(self, content, forum_id):
forum = self.env['forum.forum'].browse(forum_id)
if content and self.env.user.karma < forum.karma_dofollow:
for match in re.findall(r'<a\s.*href=".*?">', content):
escaped_match = re.escape(match) # replace parenthesis or special char in regex
url_match = re.match(r'^.*href="(.*)".*', match) # extracting the link allows to rebuild a clean link tag
url = url_match.group(1)
content = re.sub(escaped_match, f'<a rel="nofollow" href="{url}">', content)
if self.env.user.karma < forum.karma_editor:
filter_regexp = r'(<img.*?>)|(<a[^>]*?href[^>]*?>)|(<[a-z|A-Z]+[^>]*style\s*=\s*[\'"][^\'"]*\s*background[^:]*:[^url;]*url)'
content_match = re.search(filter_regexp, content, re.I)
if content_match:
raise AccessError(_('%d karma required to post an image or link.', forum.karma_editor))
return content
# ----------------------------------------------------------------------
# BUSINESS
# ----------------------------------------------------------------------
def post_notification(self):
for post in self:
tag_partners = post.tag_ids.sudo().mapped('message_partner_ids')
if post.state == 'active' and post.parent_id:
post.parent_id.message_post_with_source(
'website_forum.forum_post_template_new_answer',
subject=_('Re: %s', post.parent_id.name),
partner_ids=tag_partners.ids,
subtype_xmlid='website_forum.mt_answer_new',
)
elif post.state == 'active' and not post.parent_id:
post.message_post_with_source(
'website_forum.forum_post_template_new_question',
subject=post.name,
partner_ids=tag_partners.ids,
subtype_xmlid='website_forum.mt_question_new',
)
elif post.state == 'pending' and not post.parent_id:
# TDE FIXME: in master, you should probably use a subtype;
# however here we remove subtype but set partner_ids
partners = post.sudo().message_partner_ids | tag_partners
partners = partners.filtered(lambda partner: partner.user_ids and any(user.karma >= post.forum_id.karma_moderate for user in partner.user_ids))
post.message_post_with_source(
'website_forum.forum_post_template_validation',
subject=post.name,
partner_ids=partners.ids,
subtype_xmlid='mail.mt_note',
)
return True
def reopen(self):
if any(post.parent_id or post.state != 'close' for post in self):
return False
reason_offensive = self.env.ref('website_forum.reason_7')
reason_spam = self.env.ref('website_forum.reason_8')
for post in self:
if post.closed_reason_id in (reason_offensive, reason_spam):
_logger.info('Upvoting user <%s>, reopening spam/offensive question',
post.create_uid)
karma = post.forum_id.karma_gen_answer_flagged
if post.closed_reason_id == reason_spam:
# If first post, increase the karma to add
count_post = post.search_count([('parent_id', '=', False), ('forum_id', '=', post.forum_id.id), ('create_uid', '=', post.create_uid.id)])
if count_post == 1:
karma *= 10
post.create_uid.sudo()._add_karma(karma * -1, post, _('Reopen a banned question'))
self.sudo().write({'state': 'active'})
def close(self, reason_id):
if any(post.parent_id for post in self):
return False
reason_offensive = self.env.ref('website_forum.reason_7').id
reason_spam = self.env.ref('website_forum.reason_8').id
if reason_id in (reason_offensive, reason_spam):
for post in self:
_logger.info('Downvoting user <%s> for posting spam/offensive contents',
post.create_uid)
karma = post.forum_id.karma_gen_answer_flagged
if reason_id == reason_spam:
# If first post, increase the karma to remove
count_post = post.search_count([('parent_id', '=', False), ('forum_id', '=', post.forum_id.id), ('create_uid', '=', post.create_uid.id)])
if count_post == 1:
karma *= 10
message = (
_('Post is closed and marked as spam')
if reason_id == reason_spam else
_('Post is closed and marked as offensive content')
)
post.create_uid.sudo()._add_karma(karma, post, message)
self.write({
'state': 'close',
'closed_uid': self._uid,
'closed_date': datetime.today().strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT),
'closed_reason_id': reason_id,
})
return True
def validate(self):
for post in self:
if not post.can_moderate:
raise AccessError(_('%d karma required to validate a post.', post.forum_id.karma_moderate))
# if state == pending, no karma previously added for the new question
if post.state == 'pending':
post.create_uid.sudo()._add_karma(
post.forum_id.karma_gen_question_new,
post,
_('Ask a question'),
)
post.write({
'state': 'active',
'active': True,
'moderator_id': self.env.user.id,
})
post.post_notification()
return True
def refuse(self):
for post in self:
if not post.can_moderate:
raise AccessError(_('%d karma required to refuse a post.', post.forum_id.karma_moderate))
post.moderator_id = self.env.user
return True
def flag(self):
res = []
for post in self:
if not post.can_flag:
raise AccessError(_('%d karma required to flag a post.', post.forum_id.karma_flag))
if post.state == 'flagged':
res.append({'error': 'post_already_flagged'})
elif post.state == 'active':
# TODO: potential performance bottleneck, can be batched
post.write({
'state': 'flagged',
'flag_user_id': self.env.user.id,
})
res.append(
post.can_moderate and
{'success': 'post_flagged_moderator'} or
{'success': 'post_flagged_non_moderator'}
)
else:
res.append({'error': 'post_non_flaggable'})
return res
def mark_as_offensive(self, reason_id):
for post in self:
if not post.can_moderate:
raise AccessError(_('%d karma required to mark a post as offensive.', post.forum_id.karma_moderate))
# remove some karma
_logger.info('Downvoting user <%s> for posting spam/offensive contents', post.create_uid)
post.create_uid.sudo()._add_karma(post.forum_id.karma_gen_answer_flagged, post, _('Downvote for posting offensive contents'))
# TODO: potential bottleneck, could be done in batch
post.write({
'state': 'offensive',
'moderator_id': self.env.user.id,
'closed_date': fields.Datetime.now(),
'closed_reason_id': reason_id,
'active': False,
})
return True
def mark_as_offensive_batch(self, key, values):
spams = self.browse()
if key == 'create_uid':
spams = self.filtered(lambda x: x.create_uid.id in values)
elif key == 'country_id':
spams = self.filtered(lambda x: x.create_uid.country_id.id in values)
elif key == 'post_id':
spams = self.filtered(lambda x: x.id in values)
reason_id = self.env.ref('website_forum.reason_8').id
_logger.info('User %s marked as spams (in batch): %s' % (self.env.uid, spams))
return spams.mark_as_offensive(reason_id)
def vote(self, upvote=True):
self.ensure_one()
Vote = self.env['forum.post.vote']
existing_vote = Vote.search([('post_id', '=', self.id), ('user_id', '=', self._uid)])
new_vote_value = '1' if upvote else '-1'
if existing_vote:
if upvote:
new_vote_value = '0' if existing_vote.vote == '-1' else '1'
else:
new_vote_value = '0' if existing_vote.vote == '1' else '-1'
existing_vote.vote = new_vote_value
else:
Vote.create({'post_id': self.id, 'vote': new_vote_value})
return {'vote_count': self.vote_count, 'user_vote': new_vote_value}
def convert_answer_to_comment(self):
""" Tools to convert an answer (forum.post) to a comment (mail.message).
The original post is unlinked and a new comment is posted on the question
using the post create_uid as the comment's author. """
self.ensure_one()
if not self.parent_id:
return self.env['mail.message']
# karma-based action check: use the post field that computed own/all value
if not self.can_comment_convert:
raise AccessError(_('%d karma required to convert an answer to a comment.', self.karma_comment_convert))
# post the message
question = self.parent_id
self_sudo = self.sudo()
values = {
'author_id': self_sudo.create_uid.partner_id.id, # use sudo here because of access to res.users model
'email_from': self_sudo.create_uid.email_formatted, # use sudo here because of access to res.users model
'body': tools.html_sanitize(self.content, sanitize_attributes=True, strip_style=True, strip_classes=True),
'message_type': 'comment',
'subtype_xmlid': 'mail.mt_comment',
'date': self.create_date,
}
# done with the author user to have create_uid correctly set
new_message = question.with_user(self_sudo.create_uid.id).with_context(mail_create_nosubscribe=True).sudo().message_post(**values).sudo(False)
# unlink the original answer, using SUPERUSER_ID to avoid karma issues
self.sudo().unlink()
return new_message
@api.model
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_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_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
can_convert = self.env.user.karma >= karma_convert
if not can_convert:
if is_author and karma_own < karma_all:
raise AccessError(_('%d karma required to convert your comment to an answer.', karma_own))
else:
raise AccessError(_('%d karma required to convert a comment to an answer.', karma_all))
# check the message's author has not already an answer
question = post.parent_id if post.parent_id else post
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_sudo.body,
'parent_id': question.id,
'name': _('Re: %s', question.name or ''),
}
# done with the author user to have create_uid correctly set
new_post = self.with_user(post_create_uid).sudo().create(post_values).sudo(False)
# delete comment
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:
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_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
)
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
# ----------------------------------------------------------------------
@api.model
def _get_mail_message_access(self, res_ids, operation, model_name=None):
# XDO FIXME: to be correctly fixed with new _get_mail_message_access and filter access rule
if operation in ('write', 'unlink') and (not model_name or model_name == 'forum.post'):
# Make sure only author or moderator can edit/delete messages
for post in self.browse(res_ids):
if not post.can_edit:
raise AccessError(_('%d karma required to edit a post.', post.karma_edit))
return super(Post, self)._get_mail_message_access(res_ids, operation, model_name=model_name)
def _notify_get_recipients_groups(self, message, model_description, msg_vals=None):
""" Add access button to everyone if the document is active. """
groups = super()._notify_get_recipients_groups(
message, model_description, msg_vals=msg_vals
)
if not self:
return groups
self.ensure_one()
if self.state == 'active':
for _group_name, _group_method, group_data in groups:
group_data['has_button_access'] = True
return groups
@api.returns('mail.message', lambda value: value.id)
def message_post(self, *, message_type='notification', **kwargs):
if self.ids and message_type == 'comment': # user comments have a restriction on karma
# add followers of comments on the parent post
if self.parent_id:
partner_ids = kwargs.get('partner_ids', [])
comment_subtype = self.sudo().env.ref('mail.mt_comment')
question_followers = self.env['mail.followers'].sudo().search([
('res_model', '=', self._name),
('res_id', '=', self.parent_id.id),
('partner_id', '!=', False),
]).filtered(lambda fol: comment_subtype in fol.subtype_ids).mapped('partner_id')
partner_ids += question_followers.ids
kwargs['partner_ids'] = partner_ids
self.ensure_one()
if not self.can_comment:
raise AccessError(_('%d karma required to comment.', self.karma_comment))
if not kwargs.get('record_name') and self.parent_id:
kwargs['record_name'] = self.parent_id.name
return super(Post, self).message_post(message_type=message_type, **kwargs)
def _notify_thread_by_inbox(self, message, recipients_data, msg_vals=False, **kwargs):
""" Override to avoid keeping all notified recipients of a comment.
We avoid tracking needaction on post comments. Only emails should be
sufficient. """
if msg_vals is None:
msg_vals = {}
if msg_vals.get('message_type', message.message_type) == 'comment':
return
return super(Post, self)._notify_thread_by_inbox(message, recipients_data, msg_vals=msg_vals, **kwargs)
# ----------------------------------------------------------------------
# WEBSITE
# ----------------------------------------------------------------------
def go_to_website(self):
self.ensure_one()
if not self.website_url:
return False
return self.env['website'].get_client_action(self.website_url)
@api.model
def _search_get_detail(self, website, order, options):
with_description = options['displayDescription']
with_date = options['displayDetail']
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},
}
domain = website.website_domain()
domain = expression.AND([domain, [('state', '=', 'active'), ('can_view', '=', True)]])
include_answers = options.get('include_answers', False)
if not include_answers:
domain = expression.AND([domain, [('parent_id', '=', False)]])
forum = options.get('forum')
if forum:
domain = expression.AND([domain, [('forum_id', '=', unslug(forum)[1])]])
tags = options.get('tag')
if tags:
domain = expression.AND([domain, [('tag_ids', 'in', [unslug(tag)[1] for tag in tags.split(',')])]])
filters = options.get('filters')
if filters == 'unanswered':
domain = expression.AND([domain, [('child_ids', '=', False)]])
elif filters == 'solved':
domain = expression.AND([domain, [('has_validated_answer', '=', True)]])
elif filters == 'unsolved':
domain = expression.AND([domain, [('has_validated_answer', '=', False)]])
user = self.env.user
my = options.get('my')
create_uid = user.id if my == 'mine' else options.get('create_uid')
if create_uid:
domain = expression.AND([domain, [('create_uid', '=', create_uid)]])
if my == 'followed':
domain = expression.AND([domain, [('message_partner_ids', '=', user.partner_id.id)]])
elif my == 'tagged':
domain = expression.AND([domain, [('tag_ids.message_partner_ids', '=', user.partner_id.id)]])
elif my == 'favourites':
domain = expression.AND([domain, [('favourite_ids', '=', user.id)]])
elif my == 'upvoted':
domain = expression.AND([domain, [('vote_ids.user_id', '=', user.id)]])
# 'sorting' from the form's "Order by" overrides order during auto-completion
order = options.get('sorting', order)
if 'is_published' in order:
parts = [part for part in order.split(',') if 'is_published' not in part]
order = ','.join(parts)
if with_description:
search_fields.append('content')
fetch_fields.append('content')
mapping['description'] = {'name': 'content', 'type': 'text', 'html': True, 'match': True}
if with_date:
fetch_fields.append('write_date')
mapping['detail'] = {'name': 'date', 'type': 'html'}
return {
'model': 'forum.post',
'base_domain': [domain],
'search_fields': search_fields,
'fetch_fields': fetch_fields,
'mapping': mapping,
'icon': 'fa-comment-o',
'order': order,
}
def _search_render_results(self, fetch_fields, mapping, icon, limit):
with_date = 'detail' in mapping
results_data = super()._search_render_results(fetch_fields, mapping, icon, limit)
for post, data in zip(self, results_data):
if with_date:
data['date'] = self.env['ir.qweb.field.date'].record_to_html(post, 'write_date', {})
return results_data

View File

@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class PostReason(models.Model):
_name = "forum.post.reason"
_description = "Post Closing Reason"
_order = 'name'
name = fields.Char(string='Closing Reason', required=True, translate=True)
reason_type = fields.Selection([('basic', 'Basic'), ('offensive', 'Offensive')], string='Reason Type', default='basic')

113
models/forum_post_vote.py Normal file
View File

@ -0,0 +1,113 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import UserError, AccessError
class Vote(models.Model):
_name = 'forum.post.vote'
_description = 'Post Vote'
_order = 'create_date desc, id desc'
post_id = fields.Many2one('forum.post', string='Post', ondelete='cascade', required=True)
user_id = fields.Many2one('res.users', string='User', required=True, default=lambda self: self._uid, ondelete='cascade')
vote = fields.Selection([('1', '1'), ('-1', '-1'), ('0', '0')], string='Vote', required=True, default='1')
create_date = fields.Datetime('Create Date', index=True, readonly=True)
forum_id = fields.Many2one('forum.forum', string='Forum', related="post_id.forum_id", store=True, readonly=False)
recipient_id = fields.Many2one('res.users', string='To', related="post_id.create_uid", store=True, readonly=False)
_sql_constraints = [
('vote_uniq', 'unique (post_id, user_id)', "Vote already exists!"),
]
def _get_karma_value(self, old_vote, new_vote, up_karma, down_karma):
"""Return the karma to add / remove based on the old vote and on the new vote."""
karma_values = {'-1': down_karma, '0': 0, '1': up_karma}
karma = karma_values[new_vote] - karma_values[old_vote]
if old_vote == new_vote:
reason = _('no changes')
elif new_vote == '1':
reason = _('upvoted')
elif new_vote == '-1':
reason = _('downvoted')
elif old_vote == '1':
reason = _('no more upvoted')
else:
reason = _('no more downvoted')
return karma, reason
@api.model_create_multi
def create(self, vals_list):
# can't modify owner of a vote
if not self.env.is_admin():
for vals in vals_list:
vals.pop('user_id', None)
votes = super(Vote, self).create(vals_list)
for vote in votes:
vote._check_general_rights()
vote._check_karma_rights(vote.vote == '1')
# karma update
vote._vote_update_karma('0', vote.vote)
return votes
def write(self, values):
# can't modify owner of a vote
if not self.env.is_admin():
values.pop('user_id', None)
for vote in self:
vote._check_general_rights(values)
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, vote_value)
res = super(Vote, self).write(values)
return res
def _check_general_rights(self, vals=None):
if vals is None:
vals = {}
post = self.post_id
if vals.get('post_id'):
post = self.env['forum.post'].browse(vals.get('post_id'))
if not self.env.is_admin():
# own post check
if self._uid == post.create_uid.id:
raise UserError(_('It is not allowed to vote for its own post.'))
# own vote check
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=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))
elif not upvote and not self.post_id.can_downvote:
raise AccessError(_('%d karma required to downvote.', self.post_id.forum_id.karma_downvote))
def _vote_update_karma(self, old_vote, new_vote):
if self.post_id.parent_id:
karma, reason = self._get_karma_value(
old_vote,
new_vote,
self.forum_id.karma_gen_answer_upvote,
self.forum_id.karma_gen_answer_downvote)
source = _('Answer %s', reason)
else:
karma, reason = self._get_karma_value(
old_vote,
new_vote,
self.forum_id.karma_gen_question_upvote,
self.forum_id.karma_gen_question_downvote)
source = _('Question %s', reason)
self.recipient_id.sudo()._add_karma(karma, self.post_id, source)

72
models/forum_tag.py Normal file
View File

@ -0,0 +1,72 @@
# -*- coding: utf-8 -*-
# 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.searchable.mixin',
'website.seo.metadata',
]
name = fields.Char('Name', 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!"),
]
@api.depends("post_ids", "post_ids.tag_ids", "post_ids.state", "post_ids.active")
def _compute_posts_count(self):
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:
forum = self.env['forum.forum'].browse(vals.get('forum_id'))
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(','))),
}

View File

@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields
class Challenge(models.Model):
_inherit = 'gamification.challenge'
challenge_category = fields.Selection(selection_add=[
('forum', 'Website / Forum')
], ondelete={'forum': 'set default'})

View File

@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class KarmaTracking(models.Model):
_inherit = 'gamification.karma.tracking'
def _get_origin_selection_values(self):
return super()._get_origin_selection_values() + [('forum.post', self.env['ir.model']._get('forum.post').display_name)]

23
models/ir_attachment.py Normal file
View File

@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class Attachment(models.Model):
_inherit = "ir.attachment"
def _can_bypass_rights_on_media_dialog(self, **attachment_data):
# Bypass the attachment create ACL and let the user create the image
# 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')
if (
res_model == 'forum.post' and res_id
and self.env['forum.post'].browse(res_id).can_use_full_editor
):
return True
return super()._can_bypass_rights_on_media_dialog(**attachment_data)

22
models/res_users.py Normal file
View File

@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, fields, models
class Users(models.Model):
_inherit = 'res.users'
create_date = fields.Datetime('Create Date', readonly=True, index=True)
# Wrapper for call_kw with inherits
def open_website_url(self):
return self.mapped('partner_id').open_website_url()
def get_gamification_redirection_data(self):
res = super().get_gamification_redirection_data()
res.append({
'label': _('See our Forum'),
'url': '/forum',
})
return res

57
models/website.py Normal file
View File

@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields, api, _
from odoo.addons.http_routing.models.ir_http import url_for
class Website(models.Model):
_inherit = 'website'
forum_count = fields.Integer(readonly=True, default=0)
@api.model_create_multi
def create(self, vals_list):
websites = super().create(vals_list)
websites._update_forum_count()
return websites
def get_suggested_controllers(self):
suggested_controllers = super(Website, self).get_suggested_controllers()
suggested_controllers.append((_('Forum'), url_for('/forum'), 'website_forum'))
return suggested_controllers
def configurator_get_footer_links(self):
links = super().configurator_get_footer_links()
links.append({'text': _("Forum"), 'href': '/forum'})
return links
def configurator_set_menu_links(self, menu_company, module_data):
# Forum menu should only be a footer link, not a menu
forum_menu = self.env['website.menu'].search([('url', '=', '/forum'), ('website_id', '=', self.id)])
forum_menu.unlink()
super().configurator_set_menu_links(menu_company, module_data)
def _search_get_details(self, search_type, order, options):
result = super()._search_get_details(search_type, order, options)
if search_type in ['forums', 'forums_only', 'all']:
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):
""" Update count of forum linked to some websites. This has to be
done manually as website_id=False on forum model means a shared forum.
There is therefore no straightforward relationship to be used between
forum and website.
This method either runs on self (if not void), either on all existing
websites (to update globally counters, notably when a new forum is
created). """
websites = self if self else self.search([])
forums_all = self.env['forum.forum'].search([])
for website in websites:
website.forum_count = len(forums_all.filtered_domain(website.website_domain()))

6
populate/__init__.py Normal file
View File

@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import forum_forum
from . import forum_post
from . import mail_message

15
populate/forum_forum.py Normal file
View File

@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
from odoo.tools import populate
class Forum(models.Model):
_inherit = 'forum.forum'
_populate_sizes = {'small': 1, 'medium': 3, 'large': 10}
def _populate_factories(self):
return [
('name', populate.constant('Forum_{counter}')),
('description', populate.constant('This is forum number {counter}'))
]

84
populate/forum_post.py Normal file
View File

@ -0,0 +1,84 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from odoo import fields, models
from odoo.tools import populate
QA_WEIGHTS = {0: 25, 1: 35, 2: 20, 3: 10, 4: 4, 5: 3, 6: 2, 7: 1}
_logger = logging.getLogger(__name__)
class Post(models.Model):
_inherit = 'forum.post'
# Include an additional average of 2 post answers for each given size
# e.g.: 100 posts as populated_model_records = ~300 actual forum.posts records
_populate_sizes = {'small': 100, 'medium': 1000, 'large': 90000}
_populate_dependencies = ['forum.forum', 'res.users']
def _populate_factories(self):
forum_ids = self.env.registry.populated_models['forum.forum']
hours = [_ for _ in range(1, 49)]
random = populate.Random('forum_posts')
def create_answers(values=None, **kwargs):
"""Create random number of answers
We set the `last_activity_date` to convert it to `create_date` in `_populate`
as the ORM prevents setting these.
"""
return [
fields.Command.create({
'name': f"reply to {values['name']}",
'forum_id': values['forum_id'],
'content': 'Answer content',
'last_activity_date': fields.Datetime.add(values['last_activity_date'], hours=random.choice(hours)),
})
for _ in range(random.choices(*zip(*QA_WEIGHTS.items()))[0])
]
def get_last_activity_date(iterator, *args):
days = [_ for _ in range(3, 93)]
now = fields.Datetime.now()
for values in iterator:
values.update(last_activity_date=fields.Datetime.subtract(now, days=random.choice(days)))
yield values
return [
('forum_id', populate.randomize(forum_ids)),
('name', populate.constant('post_{counter}')),
('last_activity_date', get_last_activity_date), # Must be before call to 'create_answers'
('child_ids', populate.compute(create_answers)),
]
def _populate(self, size):
records = super()._populate(size)
user_ids = self.env.registry.populated_models['res.users']
# Overwrite auto-fields: use last_activity_date to update create date
_logger.info('forum.post: update create date and uid')
question_ids = tuple(records.ids)
query = """
SELECT setseed(0.5);
UPDATE forum_post
SET create_date = last_activity_date,
create_uid = floor(random() * (%(max_value)s - %(min_value)s + 1) + %(min_value)s)
WHERE id in %(question_ids)s or parent_id in %(question_ids)s
"""
self.env.cr.execute(query, {'question_ids': question_ids, 'min_value': user_ids[0], 'max_value': user_ids[-1]})
_logger.info('forum.post: update last_activity_date of questions with answers')
query = """
WITH latest_answer AS(
SELECT parent_id, max(last_activity_date) as answer_date
FROM forum_post
WHERE parent_id in %(question_ids)s
GROUP BY parent_id
)
UPDATE forum_post fp
SET last_activity_date = latest_answer.answer_date
FROM latest_answer
WHERE fp.id = latest_answer.parent_id
"""
self.env.cr.execute(query, {'question_ids': question_ids})
return records

82
populate/mail_message.py Normal file
View File

@ -0,0 +1,82 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from odoo import fields, models
from odoo.tools import populate
CP_WEIGHTS = {1: 35, 2: 30, 3: 25, 4: 10}
_logger = logging.getLogger(__name__)
class Message(models.Model):
_inherit = 'mail.message'
@property
def _populate_dependencies(self):
return super()._populate_dependencies + ["res.users", "forum.post"]
def _populate(self, size):
"""Randomly assign messages to some populated posts and answers.
This makes sure these questions and answers get one to four comments.
Also define a date that is more recent than the post/answer's create_date
"""
records = super()._populate(size)
comment_subtype = self.env.ref('mail.mt_comment')
hours = [_ for _ in range(1, 24)]
random = populate.Random('comments_on_forum_posts')
users = self.env["res.users"].browse(self.env.registry.populated_models['res.users'])
vals_list = []
question_ids = self.env.registry.populated_models['forum.post']
posts = self.env['forum.post'].search(['|', ('id', 'in', question_ids), ('parent_id', 'in', question_ids)])
for post in random.sample(posts, int(len(question_ids) * 0.7)):
nb_comments = random.choices(*zip(*CP_WEIGHTS.items()))[0]
for counter in range(nb_comments):
vals_list.append({
"author_id": random.choice(users.partner_id.ids),
"body": f"message_body_{counter}",
"date": fields.Datetime.add(post.create_date, hours=random.choice(hours)),
"message_type": "comment",
"model": "forum.post",
"res_id": post.id,
"subtype_id": comment_subtype.id,
})
messages = self.env["mail.message"].create(vals_list)
_logger.info('mail.message: update comments create date and uid')
_logger.info('forum.post: update last_activity_date for posts with comments and/or commented answers')
query = """
WITH comment_author AS(
SELECT mm.id, mm.author_id, ru.id as user_id, ru.partner_id
FROM mail_message mm
JOIN res_users ru
ON mm.author_id = ru.partner_id
WHERE mm.id in %(comment_ids)s
),
updated_comments as (
UPDATE mail_message mm
SET create_date = date,
create_uid = ca.user_id
FROM comment_author ca
WHERE mm.id = ca.id
RETURNING res_id as post_id, create_date as comment_date
),
max_comment_dates AS (
SELECT post_id, max(comment_date) as last_comment_date
FROM updated_comments
GROUP BY post_id
),
updated_posts AS (
UPDATE forum_post fp
SET last_activity_date = CASE --on questions, answer could be more recent
WHEN fp.parent_id IS NOT NULL THEN greatest(last_activity_date, last_comment_date)
ELSE last_comment_date END
FROM max_comment_dates
WHERE max_comment_dates.post_id = fp.id
RETURNING fp.id as post_id, fp.last_activity_date as last_activity_date, fp.parent_id as parent_id
)
UPDATE forum_post fp
SET last_activity_date = greatest(fp.last_activity_date, up.last_activity_date)
FROM updated_posts up
WHERE up.parent_id = fp.id
"""
self.env.cr.execute(query, {'comment_ids': tuple(messages.ids)})
return records + messages

View File

@ -0,0 +1,17 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_forum_forum_public,forum.forum,model_forum_forum,base.group_public,1,0,0,0
access_forum_forum_portal,forum.forum,model_forum_forum,base.group_portal,1,0,0,0
access_forum_forum_employee,forum.forum,model_forum_forum,base.group_user,1,0,0,0
access_forum_forum_manager,forum.forum.maanger,model_forum_forum,base.group_erp_manager,1,1,1,1
access_forum_post_public,forum.post.public,model_forum_post,base.group_public,1,0,0,0
access_forum_post_portal,forum.post.portal,model_forum_post,base.group_portal,1,1,1,1
access_forum_post_user,forum.post.user,model_forum_post,base.group_user,1,1,1,1
access_forum_post_vote_public,forum.post.vote.public,model_forum_post_vote,base.group_public,1,0,0,0
access_forum_post_vote_portal,orum.post.vote.portal,model_forum_post_vote,base.group_portal,1,1,1,0
access_forum_post_vote_user,forum.post.vote.user,model_forum_post_vote,base.group_user,1,1,1,1
access_forum_post_reason_public,forum.post.reason.public,model_forum_post_reason,base.group_public,1,0,0,0
access_forum_post_reason_portal,forum.post.reason.portal,model_forum_post_reason,base.group_portal,1,0,0,0
access_forum_post_reason_user,forum.post.reason.user,model_forum_post_reason,base.group_user,1,1,1,1
access_forum_tag_public,forum.tag.public,model_forum_tag,base.group_public,1,0,1,0
access_forum_tag_portal,forum.tag.portal,model_forum_tag,base.group_portal,1,0,1,0
access_forum_tag_user,forum.tag.user,model_forum_tag,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_forum_forum_public forum.forum model_forum_forum base.group_public 1 0 0 0
3 access_forum_forum_portal forum.forum model_forum_forum base.group_portal 1 0 0 0
4 access_forum_forum_employee forum.forum model_forum_forum base.group_user 1 0 0 0
5 access_forum_forum_manager forum.forum.maanger model_forum_forum base.group_erp_manager 1 1 1 1
6 access_forum_post_public forum.post.public model_forum_post base.group_public 1 0 0 0
7 access_forum_post_portal forum.post.portal model_forum_post base.group_portal 1 1 1 1
8 access_forum_post_user forum.post.user model_forum_post base.group_user 1 1 1 1
9 access_forum_post_vote_public forum.post.vote.public model_forum_post_vote base.group_public 1 0 0 0
10 access_forum_post_vote_portal orum.post.vote.portal model_forum_post_vote base.group_portal 1 1 1 0
11 access_forum_post_vote_user forum.post.vote.user model_forum_post_vote base.group_user 1 1 1 1
12 access_forum_post_reason_public forum.post.reason.public model_forum_post_reason base.group_public 1 0 0 0
13 access_forum_post_reason_portal forum.post.reason.portal model_forum_post_reason base.group_portal 1 0 0 0
14 access_forum_post_reason_user forum.post.reason.user model_forum_post_reason base.group_user 1 1 1 1
15 access_forum_tag_public forum.tag.public model_forum_tag base.group_public 1 0 1 0
16 access_forum_tag_portal forum.tag.portal model_forum_tag base.group_portal 1 0 1 0
17 access_forum_tag_user forum.tag.user model_forum_tag base.group_user 1 1 1 1

74
security/ir_rule_data.xml Normal file
View File

@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="website_forum_public" model="ir.rule">
<field name="name">Website forum: Public user can only access to public forum</field>
<field name="model_id" ref="model_forum_forum"/>
<field name="domain_force">[('privacy', '=', 'public')]</field>
<field name="groups" eval="[(4, ref('base.group_public'))]"/>
</record>
<record id="website_forum_connected" model="ir.rule">
<field name="name">Website forum: User can only access to public (or authorized) forum</field>
<field name="model_id" ref="model_forum_forum"/>
<field name="domain_force">[
'|',
('privacy', 'in', ['public', 'connected']),
'&amp;',
('privacy', '=', 'private'),
('authorized_group_id', 'in', user.groups_id.ids)]</field>
<field name="groups" eval="[(4, ref('base.group_portal')), (4, ref('base.group_user'))]"/>
</record>
<record id="website_forum_create_website_designer" model="ir.rule">
<field name="name">Website forum: Website designer can create private forum</field>
<field name="model_id" ref="model_forum_forum"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('website.group_website_designer'))]"/>
<field name="perm_unlink" eval="0"/>
<field name="perm_write" eval="0"/>
<field name="perm_read" eval="0"/>
<field name="perm_create" eval="1"/>
</record>
<record id="website_forum_private" model="ir.rule">
<field name="name">Website forum: All access for manager</field>
<field name="model_id" ref="model_forum_forum"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('base.group_erp_manager'))]"/>
</record>
<record id="website_forum_public_post" model="ir.rule">
<field name="name">Website forum post: Public user can only access to public post</field>
<field name="model_id" ref="model_forum_post"/>
<field name="domain_force">[('forum_id.privacy', '=', 'public')]</field>
<field name="groups" eval="[(4, ref('base.group_public'))]"/>
</record>
<record id="website_forum_connected_post" model="ir.rule">
<field name="name">Website forum post: User can only access to public (or authorized) post</field>
<field name="model_id" ref="model_forum_post"/>
<field name="domain_force">['|', ('forum_id.privacy', 'in', ['public', 'connected']), '&amp;', ('forum_id.privacy', '=', 'private'), ('forum_id.authorized_group_id', 'in', user.groups_id.ids)]</field>
<field name="groups" eval="[(4, ref('base.group_portal')), (4, ref('base.group_user'))]"/>
</record>
<record id="website_forum_private_post" model="ir.rule">
<field name="name">Website forum post : All access for manager</field>
<field name="model_id" ref="model_forum_post"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('base.group_erp_manager'))]"/>
</record>
<record id="website_forum_public_tag" model="ir.rule">
<field name="name">Website forum tag: Public user can only access to tag linked to public forum</field>
<field name="model_id" ref="model_forum_tag"/>
<field name="domain_force">[('forum_id.privacy', '=', 'public')]</field>
<field name="groups" eval="[(4, ref('base.group_public'))]"/>
</record>
<record id="website_forum_connected_tag" model="ir.rule">
<field name="name">Website forum tag: User can only access to tag linked to public (or authorized) forum</field>
<field name="model_id" ref="model_forum_tag"/>
<field name="domain_force">['|', ('forum_id.privacy', 'in', ['public', 'connected']), '&amp;', ('forum_id.privacy', '=', 'private'), ('forum_id.authorized_group_id', 'in', user.groups_id.ids)]</field>
<field name="groups" eval="[(4, ref('base.group_portal')), (4, ref('base.group_user'))]"/>
</record>
<record id="website_forum_private_tag" model="ir.rule">
<field name="name">Website forum tag : Manager user can access to all tags</field>
<field name="model_id" ref="model_forum_tag"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('base.group_erp_manager'))]"/>
</record>
</odoo>

BIN
static/description/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -0,0 +1 @@
<svg width="50" height="50" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#o_icon_website_forum__a)"><path d="M26.001 34.5s-.83-2-.997-2.167c20.502-9.84 23.518-3.54 23.94-.955.068.418.005.843-.124 1.247A24.857 24.857 0 0 1 47 36.89c-1.5 1.5-10 1.61-10 1.61l-11-4Z" fill="#962B48"/><path d="m21 14 4.004 3.667c-20.502 9.84-23.518 3.54-23.94.955-.068-.418-.005-.843.124-1.247a24.84 24.84 0 0 1 1.428-3.514C6.713 5.644 14.501 11 14.501 11l6.5 3Z" fill="#1A6F66"/><path d="M1.187 32.625c-.129-.404-.192-.829-.124-1.247.422-2.586 3.438-8.886 23.94.955 14.197 6.815 20.01 5.89 22.382 3.818C43.286 44.36 34.803 50 25.003 50 13.855 50 4.411 42.703 1.187 32.625Z" fill="#FC868B"/><path d="M48.819 17.375c.129.404.192.829.124 1.247-.422 2.586-3.438 8.886-23.94-.955-14.197-6.815-20.01-5.89-22.382-3.818C6.72 5.64 15.203 0 25.003 0 36.15 0 45.594 7.297 48.819 17.375Z" fill="#1AD3BB"/></g><defs><clipPath id="o_icon_website_forum__a"><path fill="#fff" d="M0 0h50v50H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1012 B

View File

@ -0,0 +1,31 @@
/** @odoo-module **/
import { Component, useEffect } from "@odoo/owl";
import { useChildRef } from "@web/core/utils/hooks";
import { Dialog } from "@web/core/dialog/dialog";
export class FlagMarkAsOffensiveDialog extends Component {
static template = "website_forum.FlagMarkAsOffensiveDialog";
static components = { Dialog };
setup() {
this.modalRef = useChildRef();
const onClickDiscard = (ev) => {
ev.preventDefault();
this.props.close();
};
useEffect(
(discardButton) => {
if (discardButton) {
discardButton.addEventListener("click", onClickDiscard);
return () => {
discardButton.removeEventListener("click", onClickDiscard);
};
}
},
() => [this.modalRef.el?.querySelector(".btn-link")]
);
}
}

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="website_forum.FlagMarkAsOffensiveDialog">
<Dialog size="'md'" title="props.title" footer="false" modalRef="modalRef" >
<t t-out="props.body"/>
</Dialog>
</t>
</templates>

1
static/src/img/empty.svg Normal file
View File

@ -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

BIN
static/src/img/help.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

5
static/src/img/tasks.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,20 @@
/** @odoo-module **/
import {NewContentFormController, NewContentFormView} from '@website/js/new_content_form';
import {registry} from "@web/core/registry";
export class AddForumFormController extends NewContentFormController {
/**
* @override
*/
computePath() {
return `/forum/${encodeURIComponent(this.model.root.resId)}`;
}
}
export const AddForumFormView = {
...NewContentFormView,
Controller: AddForumFormController,
};
registry.category("views").add("website_forum_add_form", AddForumFormView);

Some files were not shown because too many files have changed in this diff Show More