# -*- 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("""

%(message_intro)s

%(message_post)s

%(register_text)s

""") % { 'message_intro': _("Welcome!"), 'message_post': _( "Share and discuss the best content and new marketing ideas, build your professional profile and become" " a better marketer together." ), 'hide_text': _('Dismiss'), 'register_text': _('Sign up'), } # description and use name = fields.Char('Forum Name', required=True, translate=True) 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